Merge ps-w11-editor-scale: S12 editor scale + S15/S16 control surfaces (seconds-domain ADSR, zones payload v5)
This commit is contained in:
+47
-1
@@ -716,6 +716,31 @@ add_library(bank_sync STATIC src/vst/bank_sync.cpp)
|
|||||||
target_include_directories(bank_sync PUBLIC src/vst src)
|
target_include_directories(bank_sync PUBLIC src/vst src)
|
||||||
target_link_libraries(bank_sync PUBLIC assignment_request)
|
target_link_libraries(bank_sync PUBLIC assignment_request)
|
||||||
|
|
||||||
|
# browser_scroll (Phase S12) — PURE scroll-window + scrollbar-thumb + type-to-filter-search
|
||||||
|
# geometry LAYERED over the S10 capture_browser: the visible-card window, thumb rect +
|
||||||
|
# thumb-drag<->offset mapping, and the name-substring filter that composes with the bank
|
||||||
|
# filter. The mirror of capture_browser; links capture_browser (for BrowserLayout + the card
|
||||||
|
# metrics/cell rect) which pulls editor_geometry transitively. NEITHER SDK.
|
||||||
|
add_library(browser_scroll STATIC src/vst/browser_scroll.cpp)
|
||||||
|
target_include_directories(browser_scroll PUBLIC src/vst)
|
||||||
|
target_link_libraries(browser_scroll PUBLIC capture_browser)
|
||||||
|
|
||||||
|
# note_entry (Phase S12) — PURE text->clamped-MIDI-note parse for the direct numeric entry of
|
||||||
|
# a zone's low/high/root (decimal integer OR note name under the C4==60 convention, clamped to
|
||||||
|
# [0,127]). No dependency beyond the standard library. NEITHER SDK.
|
||||||
|
add_library(note_entry STATIC src/vst/note_entry.cpp)
|
||||||
|
target_include_directories(note_entry PUBLIC src/vst)
|
||||||
|
|
||||||
|
# param_slider (Phase S12 + the S15/S16 control surfaces deferred here) — PURE control-surface
|
||||||
|
# layout + hit-test + normalized value<->pixel mapping for the editor parameter panel (the
|
||||||
|
# Gate|Trigger + Varispeed|Preserve toggles and the AHDSR / Trigger / pitch-env sliders). The
|
||||||
|
# mirror of keyboard_strip; links editor_geometry for the shared Rect. Deliberately engine-free
|
||||||
|
# (no sampler_core types) — the shell owns the control-id -> param binding + the value DOMAIN
|
||||||
|
# mapping. NEITHER SDK.
|
||||||
|
add_library(param_slider STATIC src/vst/param_slider.cpp)
|
||||||
|
target_include_directories(param_slider PUBLIC src/vst)
|
||||||
|
target_link_libraries(param_slider PUBLIC editor_geometry)
|
||||||
|
|
||||||
add_executable(editor_geometry_tests tests/test_editor_geometry.cpp)
|
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)
|
||||||
@@ -755,6 +780,22 @@ add_executable(bank_sync_tests tests/test_bank_sync.cpp)
|
|||||||
target_link_libraries(bank_sync_tests PRIVATE bank_sync)
|
target_link_libraries(bank_sync_tests PRIVATE bank_sync)
|
||||||
add_test(NAME bank_sync_tests COMMAND bank_sync_tests)
|
add_test(NAME bank_sync_tests COMMAND bank_sync_tests)
|
||||||
|
|
||||||
|
# browser_scroll (S12): the pure scroll-window/thumb + search geometry over capture_browser.
|
||||||
|
add_executable(browser_scroll_tests tests/test_browser_scroll.cpp)
|
||||||
|
target_link_libraries(browser_scroll_tests PRIVATE browser_scroll)
|
||||||
|
add_test(NAME browser_scroll_tests COMMAND browser_scroll_tests)
|
||||||
|
|
||||||
|
# note_entry (S12): the pure text->clamped-MIDI-note parse for direct numeric entry.
|
||||||
|
add_executable(note_entry_tests tests/test_note_entry.cpp)
|
||||||
|
target_link_libraries(note_entry_tests PRIVATE note_entry)
|
||||||
|
add_test(NAME note_entry_tests COMMAND note_entry_tests)
|
||||||
|
|
||||||
|
# param_slider (S12 + S15/S16 control surfaces): the pure control-panel layout + slider/toggle
|
||||||
|
# value<->pixel mapping the editor parameter surface draws + routes against.
|
||||||
|
add_executable(param_slider_tests tests/test_param_slider.cpp)
|
||||||
|
target_link_libraries(param_slider_tests PRIVATE param_slider)
|
||||||
|
add_test(NAME param_slider_tests COMMAND param_slider_tests)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
|
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -941,9 +982,14 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
|
|||||||
# bank_sync (S9/S8 reader): the pure generation-compare + assignment-consume decision the
|
# bank_sync (S9/S8 reader): the pure generation-compare + assignment-consume decision the
|
||||||
# processor's off-thread poll runs; links assignment_request transitively (the decoded
|
# processor's off-thread poll runs; links assignment_request transitively (the decoded
|
||||||
# request it consumes) — the same key the extension writes, shared via the pure module.
|
# request it consumes) — the same key the extension writes, shared via the pure module.
|
||||||
|
# browser_scroll + note_entry + param_slider (S12 + S15/S16 control surfaces): the pure
|
||||||
|
# scroll/search geometry over the capture browser, the numeric-note-entry parse, and the
|
||||||
|
# control-panel layout + slider/toggle value<->pixel mapping the editor's parameter surface
|
||||||
|
# draws + routes against. browser_scroll pulls capture_browser transitively; param_slider +
|
||||||
|
# note_entry link editor_geometry / the stdlib only. All engine-free, DAW-verified in the shell.
|
||||||
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal
|
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal
|
||||||
sample_map capture_paths embed_strip app_version capture_browser keyboard_strip
|
sample_map capture_paths embed_strip app_version capture_browser keyboard_strip
|
||||||
waveform_view bank_sync)
|
waveform_view bank_sync browser_scroll note_entry param_slider)
|
||||||
# SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge;
|
# 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})
|
||||||
|
|||||||
@@ -581,19 +581,34 @@ into the voice engine; today they are fixed defaults), S5 (the map the numeric f
|
|||||||
> control, Trigger %-length/fade controls, Varispeed/Preserve engine toggle, and the AD pitch
|
> control, Trigger %-length/fade controls, Varispeed/Preserve engine toggle, and the AD pitch
|
||||||
> envelope depth/shape controls) — deferred here from S15 and S16 per spec.
|
> envelope depth/shape controls) — deferred here from S15 and S16 per spec.
|
||||||
|
|
||||||
- [ ] Scrollable, searchable capture browser: a scroll offset (wheel + scrollbar drag) so a
|
- [x] Scrollable, searchable capture browser: a scroll offset (wheel + scrollbar drag) so a
|
||||||
bank longer than the panel is fully reachable; a **type-to-filter search** that narrows
|
bank longer than the panel is fully reachable; a **type-to-filter search** that narrows
|
||||||
the drawn cards to matching display names, **composing with S10's bank filter** (bank
|
the drawn cards to matching display names, **composing with S10's bank filter** (bank
|
||||||
filter selects the bank; search narrows within it). Scroll/search layout + hit-test is
|
filter selects the bank; search narrows within it). Scroll/search layout + hit-test is
|
||||||
pure geometry (visible-card window, scrollbar thumb rect, search-box rect), layered over
|
pure geometry (visible-card window, scrollbar thumb rect, search-box rect), layered over
|
||||||
S10's `capture_browser` module; filter/scroll state is transient UI state.
|
S10's `capture_browser` module; filter/scroll state is transient UI state. **Landed:**
|
||||||
- [ ] Direct numeric entry for zone low/high/root: a click-to-edit field over the strip
|
pure `browser_scroll` module (`browser_scroll_tests`) — scrollContentHeight / max / clamp,
|
||||||
(LICE text-entry idiom or a SWELL edit control on the child HWND) so a precise note can be
|
visibleCardRange window, scrolledCardCellRect, scrollThumbRect + thumbDragToOffset inverse,
|
||||||
typed, not only dragged. Commits via `commitMapAndReload` like every other edit.
|
searchBoxRect, nameMatchesQuery + filterNameIndices. Editor shell wires wheel (`WM_MOUSEWHEEL`),
|
||||||
- [ ] ADSR editor: four draggable controls (attack/decay/sustain/release) over the S3
|
thumb-drag (`DragKind::kScrollThumb`), and the search box (`WM_CHAR` -> `onSearchChar`,
|
||||||
`AdsrParams`; per-instance component state (additive to the map/selection blob, version-
|
composed into `rebuildVisible`). Scroll/search are transient (never persisted).
|
||||||
bumped, back-compat defaults to the current fixed envelope). Slider layout/hit-test pure;
|
- [x] Direct numeric entry for zone low/high/root: a click-to-edit field over the strip
|
||||||
the audible envelope change goes through the same off-thread reload.
|
(LICE text-entry idiom) so a precise note can be typed, not only dragged. Commits via
|
||||||
|
`commitAndReload` like every other edit. **Landed:** pure `note_entry` module
|
||||||
|
(`note_entry_tests`) — `parseNoteEntry` accepts a decimal integer OR a note name (C4==60),
|
||||||
|
clamps to [0,127], rejects garbage. Editor shell hosts three focusable fields (low/high/root)
|
||||||
|
on the Zones legend, committing on Enter through `commitAndReload`.
|
||||||
|
- [x] ADSR/AHDSR editor + S15/S16 control surfaces: draggable sliders over the S3 `AdsrParams`
|
||||||
|
(attack/**hold**/decay/sustain/release — hold is the S15 addition) plus the deferred S15/S16
|
||||||
|
controls — per-zone Gate|Trigger mode toggle, Trigger %-length/fade-in/fade-out, Varispeed|
|
||||||
|
Preserve engine toggle, and the AD pitch-envelope enable/attack/decay/±semitone depth. All
|
||||||
|
edit the SELECTED zone's `ZonePlayParams` (instrument-owned, D-B; never the bank), round-trip
|
||||||
|
through the existing v3 component-state blob (no new persistence — the S15/S16 payload already
|
||||||
|
landed in the core pass), and commit off-thread via `commitAndReload`. **Landed:** pure
|
||||||
|
`param_slider` module (`param_slider_tests`) — control-panel stack layout, toggle-segment
|
||||||
|
split + hit-test, slider value<->pixel round-trip + clamping, point->control routing. The
|
||||||
|
shell owns the control-id -> engine-param binding + the value DOMAIN mapping (frames/fraction/
|
||||||
|
semitones); the module stays engine-free.
|
||||||
|
|
||||||
## S13 — drop-to-load: the S8 ingest story, folded into the editor UX (UX overhaul, part 4)
|
## S13 — drop-to-load: the S8 ingest story, folded into the editor UX (UX overhaul, part 4)
|
||||||
**Goal:** Make "load a sample into the sampler" **one gesture from the editor**: dropping
|
**Goal:** Make "load a sample into the sampler" **one gesture from the editor**: dropping
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// browser_scroll.cpp — see browser_scroll.h. PURE scroll + search geometry over the S10
|
||||||
|
// capture_browser. No host types; only the shared Rect + BrowserLayout.
|
||||||
|
|
||||||
|
#include "browser_scroll.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
|
||||||
|
namespace reasampler::vst {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// The minimum thumb height so a very long bank still yields a grabbable thumb.
|
||||||
|
constexpr int kMinThumbHeight = 20;
|
||||||
|
|
||||||
|
char asciiLower(char c) {
|
||||||
|
return static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int scrollContentHeight(const BrowserLayout& layout, int cardCount) {
|
||||||
|
if (cardCount <= 0) return 0;
|
||||||
|
const int columns = (std::max)(1, layout.columns);
|
||||||
|
const int rows = (cardCount + columns - 1) / columns; // ceil
|
||||||
|
return rows * kBrowserCardHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
int scrollMaxOffset(const BrowserLayout& layout, int cardCount) {
|
||||||
|
const int content = scrollContentHeight(layout, cardCount);
|
||||||
|
const int gridH = (std::max)(0, layout.grid.height());
|
||||||
|
return (std::max)(0, content - gridH);
|
||||||
|
}
|
||||||
|
|
||||||
|
int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset) {
|
||||||
|
const int maxOff = scrollMaxOffset(layout, cardCount);
|
||||||
|
if (proposedOffset < 0) return 0;
|
||||||
|
if (proposedOffset > maxOff) return maxOff;
|
||||||
|
return proposedOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset) {
|
||||||
|
VisibleRange vr;
|
||||||
|
if (cardCount <= 0) return vr;
|
||||||
|
const int columns = (std::max)(1, layout.columns);
|
||||||
|
const int gridH = (std::max)(0, layout.grid.height());
|
||||||
|
if (gridH <= 0 || kBrowserCardHeight <= 0) {
|
||||||
|
vr.first = 0;
|
||||||
|
vr.last = 0;
|
||||||
|
return vr;
|
||||||
|
}
|
||||||
|
if (offset < 0) offset = 0;
|
||||||
|
// First visible ROW: the topmost row whose bottom edge is below the offset. Floor so a row
|
||||||
|
// partially scrolled off the top still draws (its lower part is visible).
|
||||||
|
const int firstRow = offset / kBrowserCardHeight;
|
||||||
|
// Last visible ROW: the row containing the pixel (offset + gridH - 1), inclusive; +1 for
|
||||||
|
// the exclusive end. A row straddling the bottom edge still draws.
|
||||||
|
const int lastRow = (offset + gridH - 1) / kBrowserCardHeight + 1;
|
||||||
|
int first = firstRow * columns;
|
||||||
|
int last = lastRow * columns;
|
||||||
|
if (first > cardCount) first = cardCount;
|
||||||
|
if (last > cardCount) last = cardCount;
|
||||||
|
if (last < first) last = first;
|
||||||
|
vr.first = first;
|
||||||
|
vr.last = last;
|
||||||
|
return vr;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset) {
|
||||||
|
Rect r = cardCellRect(layout, index);
|
||||||
|
if (r.right <= r.left && r.bottom <= r.top) return r; // empty (negative index) stays empty
|
||||||
|
return Rect{r.left, r.top - offset, r.right, r.bottom - offset};
|
||||||
|
}
|
||||||
|
|
||||||
|
Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset) {
|
||||||
|
const int content = scrollContentHeight(layout, cardCount);
|
||||||
|
const int gridH = (std::max)(0, layout.grid.height());
|
||||||
|
if (content <= gridH || gridH <= 0) return Rect{}; // fits -> no scrollbar
|
||||||
|
const int maxOff = content - gridH;
|
||||||
|
if (offset < 0) offset = 0;
|
||||||
|
if (offset > maxOff) offset = maxOff;
|
||||||
|
|
||||||
|
const int trackRight = layout.grid.right;
|
||||||
|
const int trackLeft = trackRight - kScrollbarWidth;
|
||||||
|
const int trackTop = layout.grid.top;
|
||||||
|
|
||||||
|
// Thumb height proportional to the visible fraction, floored at a grabbable minimum but
|
||||||
|
// never taller than the track.
|
||||||
|
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
|
||||||
|
thumbH = (std::max)(kMinThumbHeight, thumbH);
|
||||||
|
thumbH = (std::min)(thumbH, gridH);
|
||||||
|
|
||||||
|
// Thumb top proportional to the offset over the movable track span.
|
||||||
|
const int trackSpan = gridH - thumbH; // >= 0
|
||||||
|
int thumbTop = trackTop;
|
||||||
|
if (maxOff > 0 && trackSpan > 0) {
|
||||||
|
thumbTop = trackTop + static_cast<int>(
|
||||||
|
static_cast<long long>(offset) * trackSpan / maxOff);
|
||||||
|
}
|
||||||
|
return Rect{trackLeft, thumbTop, trackRight, thumbTop + thumbH};
|
||||||
|
}
|
||||||
|
|
||||||
|
int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset,
|
||||||
|
int dyPixels) {
|
||||||
|
const int content = scrollContentHeight(layout, cardCount);
|
||||||
|
const int gridH = (std::max)(0, layout.grid.height());
|
||||||
|
if (content <= gridH || gridH <= 0) return clampScrollOffset(layout, cardCount, startOffset);
|
||||||
|
|
||||||
|
// Thumb height (same formula as scrollThumbRect) -> movable track span in thumb pixels.
|
||||||
|
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
|
||||||
|
thumbH = (std::max)(kMinThumbHeight, thumbH);
|
||||||
|
thumbH = (std::min)(thumbH, gridH);
|
||||||
|
const int trackSpan = gridH - thumbH;
|
||||||
|
if (trackSpan <= 0) return clampScrollOffset(layout, cardCount, startOffset);
|
||||||
|
|
||||||
|
const int maxOff = content - gridH;
|
||||||
|
// A 1px thumb move covers maxOff/trackSpan content px. Round to nearest for symmetry.
|
||||||
|
const long long deltaOffset =
|
||||||
|
(static_cast<long long>(dyPixels) * maxOff + (dyPixels >= 0 ? trackSpan / 2 : -trackSpan / 2)) /
|
||||||
|
trackSpan;
|
||||||
|
const long long proposed = static_cast<long long>(startOffset) + deltaOffset;
|
||||||
|
if (proposed < 0) return 0;
|
||||||
|
if (proposed > maxOff) return maxOff;
|
||||||
|
return static_cast<int>(proposed);
|
||||||
|
}
|
||||||
|
|
||||||
|
Rect searchBoxRect(int w) {
|
||||||
|
if (w <= 0) return Rect{};
|
||||||
|
return Rect{0, 0, w, kSearchBoxHeight};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool nameMatchesQuery(const std::string& name, const std::string& query) {
|
||||||
|
if (query.empty()) return true;
|
||||||
|
if (query.size() > name.size()) return false;
|
||||||
|
// Case-insensitive substring scan (ASCII fold). Small strings; a naive scan is fine.
|
||||||
|
for (std::size_t i = 0; i + query.size() <= name.size(); ++i) {
|
||||||
|
bool match = true;
|
||||||
|
for (std::size_t j = 0; j < query.size(); ++j) {
|
||||||
|
if (asciiLower(name[i + j]) != asciiLower(query[j])) {
|
||||||
|
match = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<int> filterNameIndices(const std::vector<std::string>& names,
|
||||||
|
const std::string& query) {
|
||||||
|
std::vector<int> out;
|
||||||
|
out.reserve(names.size());
|
||||||
|
for (int i = 0; i < static_cast<int>(names.size()); ++i) {
|
||||||
|
if (nameMatchesQuery(names[static_cast<std::size_t>(i)], query))
|
||||||
|
out.push_back(i);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::vst
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// browser_scroll.h — PURE scroll + type-to-filter geometry LAYERED over the S10
|
||||||
|
// capture_browser. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of
|
||||||
|
// capture_browser / editor_geometry: the fiddly scroll-window + scrollbar-thumb + search-box
|
||||||
|
// arithmetic lives here, unit-tested outside the DAW, while the editor shell draws the
|
||||||
|
// clipped card window + the scrollbar + the search field and routes wheel/drag/keystrokes
|
||||||
|
// into these functions.
|
||||||
|
//
|
||||||
|
// WHY IT EXISTS (S12). capture_browser (S10) lays out EVERY card top-down and the shell
|
||||||
|
// clips at the browser bottom — a bank longer than the panel runs off with no way to reach
|
||||||
|
// it (the S12 gap). This module adds the two things S12 layers over that stable geometry:
|
||||||
|
// * SCROLL — a vertical pixel offset into the card grid, with the max-offset clamp, the
|
||||||
|
// visible-row window, a scrollbar thumb rect, and the thumb-drag<->offset mapping so a
|
||||||
|
// wheel tick or a thumb drag reaches every card; and
|
||||||
|
// * SEARCH — a name-substring filter (case-insensitive) that narrows the drawn cards,
|
||||||
|
// COMPOSING with capture_browser's bank filter (the shell applies the bank filter first,
|
||||||
|
// then this search narrows within it) + the search-box rect the shell draws the field in.
|
||||||
|
//
|
||||||
|
// It holds NO card data and draws nothing — it knows only the browser layout (from
|
||||||
|
// capture_browser), COUNTS, and the scroll OFFSET the shell owns as transient UI state. It
|
||||||
|
// reuses capture_browser's BrowserLayout + the shared Rect (one geometry idiom).
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "capture_browser.h" // BrowserLayout, cardCellRect, kBrowserCardHeight, Rect
|
||||||
|
|
||||||
|
namespace reasampler::vst {
|
||||||
|
|
||||||
|
// The width (px) of the vertical scrollbar gutter at the right edge of the grid. The shell
|
||||||
|
// draws the track + thumb here and hit-tests thumb grabs against scrollThumbRect. Exposed so
|
||||||
|
// the shell and tests agree. When the content fits (no scroll needed) the scrollbar is
|
||||||
|
// suppressed (scrollThumbRect returns empty) and the shell may reclaim the gutter.
|
||||||
|
inline constexpr int kScrollbarWidth = 10;
|
||||||
|
|
||||||
|
// The height (px) of the type-to-filter search box the shell draws ABOVE the tab strip (a
|
||||||
|
// thin band spanning the browser width). Exposed so the shell reserves the band and tests
|
||||||
|
// agree. capture_browser's tab strip + grid sit BELOW this band (the shell offsets the
|
||||||
|
// BrowserLayout it feeds to capture_browser by kSearchBoxHeight).
|
||||||
|
inline constexpr int kSearchBoxHeight = 22;
|
||||||
|
|
||||||
|
// The total pixel HEIGHT the card grid needs to draw all `cardCount` cards at `layout`'s
|
||||||
|
// column count: the number of ROWS (ceil(cardCount / columns)) times the fixed cell height.
|
||||||
|
// Zero cards -> 0. Pure — the content extent the scroll offset ranges over.
|
||||||
|
int scrollContentHeight(const BrowserLayout& layout, int cardCount);
|
||||||
|
|
||||||
|
// The maximum scroll offset (px): content height minus the visible grid height, floored at 0.
|
||||||
|
// When the content fits within the grid this is 0 (nothing to scroll). Pure — the clamp
|
||||||
|
// ceiling for every offset the shell tracks.
|
||||||
|
int scrollMaxOffset(const BrowserLayout& layout, int cardCount);
|
||||||
|
|
||||||
|
// Clamp a proposed scroll offset into [0, scrollMaxOffset]. The shell clamps after every wheel
|
||||||
|
// tick / thumb drag so an over-scroll pins to an edge rather than showing past the last card
|
||||||
|
// or above the first. Pure.
|
||||||
|
int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset);
|
||||||
|
|
||||||
|
// The half-open range of card INDICES [first, last) at least partially visible in the grid at
|
||||||
|
// scroll `offset`. The shell draws only these cards (the S12 clip window) rather than every
|
||||||
|
// card. `offset` is assumed pre-clamped (the shell clamps on input); a first past the last row
|
||||||
|
// yields an empty range (first==last==cardCount). Pure.
|
||||||
|
struct VisibleRange {
|
||||||
|
int first = 0; // first card index drawn (inclusive)
|
||||||
|
int last = 0; // one past the last card index drawn (exclusive)
|
||||||
|
};
|
||||||
|
VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset);
|
||||||
|
|
||||||
|
// The cell rect of card `index` SHIFTED UP by the scroll offset, ready to draw (the shell
|
||||||
|
// still adds the browser sub-area origin). Equivalent to capture_browser::cardCellRect with
|
||||||
|
// the offset subtracted from top/bottom. Pure — the one place the offset applies to a card.
|
||||||
|
Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset);
|
||||||
|
|
||||||
|
// The vertical scrollbar THUMB rect within the grid's right-edge gutter, sized proportional to
|
||||||
|
// the visible fraction (grid height / content height) and positioned proportional to the
|
||||||
|
// scroll offset. Returns an EMPTY rect when the content fits (no scroll needed) — the shell
|
||||||
|
// suppresses the scrollbar then. A minimum thumb height keeps a tiny thumb grabbable on a very
|
||||||
|
// long bank. Pure — the geometry the shell draws + hit-tests the thumb grab against.
|
||||||
|
Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset);
|
||||||
|
|
||||||
|
// Map a thumb-drag to a scroll offset. Given the offset the thumb held at grab time
|
||||||
|
// (`startOffset`) and the vertical pixel delta since grab (`dyPixels`), returns the new
|
||||||
|
// (clamped) scroll offset: startOffset shifted by the delta scaled from thumb-track pixels to
|
||||||
|
// content pixels (a 1px thumb move covers content/track px of content). A degenerate track /
|
||||||
|
// fitting content pins to startOffset. Pure — the inverse of scrollThumbRect's position map.
|
||||||
|
int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, int dyPixels);
|
||||||
|
|
||||||
|
// The search-box rect: a full-width band of height kSearchBoxHeight at the TOP of the browser
|
||||||
|
// area (above where capture_browser's tab strip draws). `w` is the browser sub-area width;
|
||||||
|
// the shell adds its origin. A zero/negative width yields an empty rect. Pure.
|
||||||
|
Rect searchBoxRect(int w);
|
||||||
|
|
||||||
|
// True iff `name` contains `query` as a case-insensitive ASCII substring. An EMPTY query
|
||||||
|
// matches everything (the no-filter identity). Matching is ASCII case-folded (the display
|
||||||
|
// names are ASCII until the Phase L type kit lands, mirroring the editor's other ASCII-only
|
||||||
|
// text). Pure — the single match predicate the shell's search narrow is built from.
|
||||||
|
bool nameMatchesQuery(const std::string& name, const std::string& query);
|
||||||
|
|
||||||
|
// Narrow a list of display `names` to the INDICES whose name matches `query`, preserving
|
||||||
|
// order. An EMPTY query returns every index [0, names.size()) (the composition base so "bank
|
||||||
|
// filter, no search" == today's browser). Kept name-only (indices, not card structs) so this
|
||||||
|
// module stays free of the sample_map/bank_book chain — the shell owns the SampleChoice list
|
||||||
|
// and applies the bank filter FIRST, then feeds the surviving display names here (search
|
||||||
|
// narrows within the bank). Pure.
|
||||||
|
std::vector<int> filterNameIndices(const std::vector<std::string>& names,
|
||||||
|
const std::string& query);
|
||||||
|
|
||||||
|
} // namespace reasampler::vst
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// note_entry.cpp — see note_entry.h. PURE text->MIDI-note parse for the S12 numeric entry.
|
||||||
|
|
||||||
|
#include "note_entry.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
|
||||||
|
namespace reasampler::vst {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
char asciiUpper(char c) {
|
||||||
|
return static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string trim(const std::string& s) {
|
||||||
|
std::size_t a = 0;
|
||||||
|
std::size_t b = s.size();
|
||||||
|
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
|
||||||
|
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
|
||||||
|
return s.substr(a, b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
int clampNote(long long n) {
|
||||||
|
if (n < 0) return 0;
|
||||||
|
if (n > 127) return 127;
|
||||||
|
return static_cast<int>(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semitone offset within an octave for a note letter (C..B), or -1 for a non-letter.
|
||||||
|
int letterSemitone(char up) {
|
||||||
|
switch (up) {
|
||||||
|
case 'C': return 0;
|
||||||
|
case 'D': return 2;
|
||||||
|
case 'E': return 4;
|
||||||
|
case 'F': return 5;
|
||||||
|
case 'G': return 7;
|
||||||
|
case 'A': return 9;
|
||||||
|
case 'B': return 11;
|
||||||
|
default: return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive). MIDI 0 == C-1, 60 == C4
|
||||||
|
// (the DAW convention the editor's noteLabel uses). Returns nullopt if it is not a note name.
|
||||||
|
std::optional<int> parseNoteName(const std::string& s) {
|
||||||
|
if (s.empty()) return std::nullopt;
|
||||||
|
std::size_t i = 0;
|
||||||
|
const int base = letterSemitone(asciiUpper(s[i]));
|
||||||
|
if (base < 0) return std::nullopt; // not a letter -> not a note name
|
||||||
|
++i;
|
||||||
|
int semitone = base;
|
||||||
|
// Optional accidental(s): # / b (or 's'/'f' are NOT accepted — keep it to the two glyphs).
|
||||||
|
while (i < s.size() && (s[i] == '#' || s[i] == 'b' || s[i] == 'B')) {
|
||||||
|
// A trailing 'b'/'B' could be a flat OR the start of nothing; here after a letter it is
|
||||||
|
// an accidental. '#' raises, 'b'/'B' lowers.
|
||||||
|
if (s[i] == '#') ++semitone;
|
||||||
|
else --semitone;
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
// The octave: an optional sign then digits, running to the end.
|
||||||
|
if (i >= s.size()) return std::nullopt; // a bare "C" has no octave -> reject (ambiguous)
|
||||||
|
bool neg = false;
|
||||||
|
if (s[i] == '+' || s[i] == '-') {
|
||||||
|
neg = (s[i] == '-');
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
if (i >= s.size()) return std::nullopt;
|
||||||
|
int octave = 0;
|
||||||
|
bool anyDigit = false;
|
||||||
|
for (; i < s.size(); ++i) {
|
||||||
|
if (!std::isdigit(static_cast<unsigned char>(s[i]))) return std::nullopt;
|
||||||
|
octave = octave * 10 + (s[i] - '0');
|
||||||
|
anyDigit = true;
|
||||||
|
}
|
||||||
|
if (!anyDigit) return std::nullopt;
|
||||||
|
if (neg) octave = -octave;
|
||||||
|
// MIDI note = (octave + 1) * 12 + semitone (C-1 == 0, C4 == 60).
|
||||||
|
const long long note = static_cast<long long>(octave + 1) * 12 + semitone;
|
||||||
|
return clampNote(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<int> parseInteger(const std::string& s) {
|
||||||
|
if (s.empty()) return std::nullopt;
|
||||||
|
std::size_t i = 0;
|
||||||
|
bool neg = false;
|
||||||
|
if (s[i] == '+' || s[i] == '-') {
|
||||||
|
neg = (s[i] == '-');
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
if (i >= s.size()) return std::nullopt;
|
||||||
|
long long v = 0;
|
||||||
|
for (; i < s.size(); ++i) {
|
||||||
|
if (!std::isdigit(static_cast<unsigned char>(s[i]))) return std::nullopt;
|
||||||
|
v = v * 10 + (s[i] - '0');
|
||||||
|
if (v > 1000000) v = 1000000; // saturate; clampNote takes it to 127 anyway
|
||||||
|
}
|
||||||
|
if (neg) v = -v;
|
||||||
|
return clampNote(v);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::optional<int> parseNoteEntry(const std::string& text) {
|
||||||
|
const std::string s = trim(text);
|
||||||
|
if (s.empty()) return std::nullopt;
|
||||||
|
// Try a plain integer first (the common MIDI-number case); fall back to a note name.
|
||||||
|
if (std::isdigit(static_cast<unsigned char>(s[0])) || s[0] == '+' ||
|
||||||
|
(s[0] == '-' && s.size() > 1 && std::isdigit(static_cast<unsigned char>(s[1])))) {
|
||||||
|
if (auto n = parseInteger(s)) return n;
|
||||||
|
}
|
||||||
|
return parseNoteName(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::vst
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// note_entry.h — PURE parse + clamp for the S12 direct numeric entry of a zone's
|
||||||
|
// low/high/root MIDI note. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The
|
||||||
|
// mirror of the other pure editor helpers: the fiddly text->note parse lives here, unit-
|
||||||
|
// tested outside the DAW, while the editor shell hosts the text field (a SWELL edit control
|
||||||
|
// or a LICE text-entry idiom) and feeds the committed string here on Enter.
|
||||||
|
//
|
||||||
|
// WHY IT EXISTS (S12). Low/high/root are draggable on the keyboard strip, but a drag can't
|
||||||
|
// hit a precise note reliably. This adds a typed field: the user clicks the field, types a
|
||||||
|
// value, and presses Enter; the shell hands the raw string here to parse into a clamped MIDI
|
||||||
|
// note [0,127] and commits via the same off-thread reload as every other edit.
|
||||||
|
//
|
||||||
|
// ACCEPTED FORMS (both, so a musician OR a MIDI-number user is served):
|
||||||
|
// * a plain decimal integer ("60", " 127 ", "+5") — the raw MIDI note number; and
|
||||||
|
// * a note name ("C4", "f#3", "Bb-1") — parsed to its MIDI number under the DAW's C4==60
|
||||||
|
// convention (MIDI 0 == C-1, matching REAPER + the editor's noteLabel).
|
||||||
|
// A value out of [0,127] CLAMPS to the range (a typed 200 becomes 127) rather than
|
||||||
|
// rejecting — the least-surprising behavior for a nudge field. Unparseable input returns
|
||||||
|
// nullopt (the shell keeps the old value + may flash the field).
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace reasampler::vst {
|
||||||
|
|
||||||
|
// Parse a typed low/high/root field into a clamped MIDI note [0,127]. Accepts a decimal
|
||||||
|
// integer OR a note name (see the header notes). Leading/trailing ASCII whitespace is
|
||||||
|
// ignored. An in-range parse returns the note; an out-of-range numeric or note value clamps
|
||||||
|
// into [0,127]; empty or unparseable input returns nullopt (no change). Pure — no host types.
|
||||||
|
std::optional<int> parseNoteEntry(const std::string& text);
|
||||||
|
|
||||||
|
} // namespace reasampler::vst
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// param_slider.cpp — see param_slider.h. PURE control-surface geometry for the S12/S15/S16
|
||||||
|
// editor parameter panel. No host types; only the shared Rect + contains().
|
||||||
|
|
||||||
|
#include "param_slider.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace reasampler::vst {
|
||||||
|
|
||||||
|
std::vector<ControlRow> layoutControls(const Rect& panel,
|
||||||
|
const std::vector<ControlDesc>& controls) {
|
||||||
|
std::vector<ControlRow> out;
|
||||||
|
if (controls.empty() || panel.width() <= 0 || panel.height() <= 0) return out;
|
||||||
|
out.reserve(controls.size());
|
||||||
|
|
||||||
|
// The label column is clamped so a narrow panel still leaves a control column.
|
||||||
|
const int labelW = (std::min)(kControlLabelWidth, (std::max)(0, panel.width() / 2));
|
||||||
|
int rowTop = panel.top;
|
||||||
|
for (const ControlDesc& d : controls) {
|
||||||
|
ControlRow r;
|
||||||
|
r.id = d.id;
|
||||||
|
r.kind = d.kind;
|
||||||
|
const int rowBottom = rowTop + kControlRowHeight;
|
||||||
|
r.row = Rect{panel.left, rowTop, panel.right, rowBottom};
|
||||||
|
r.label = Rect{panel.left, rowTop, panel.left + labelW, rowBottom};
|
||||||
|
r.control = Rect{panel.left + labelW, rowTop, panel.right, rowBottom};
|
||||||
|
out.push_back(r);
|
||||||
|
rowTop = rowBottom + kControlRowGap;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rect toggleSegmentRect(const Rect& control, int seg) {
|
||||||
|
if (seg < 0 || seg >= kToggleSegments) return Rect{};
|
||||||
|
const int w = control.width();
|
||||||
|
if (w <= 0 || control.height() <= 0) return Rect{};
|
||||||
|
const int segW = w / kToggleSegments;
|
||||||
|
const int left = control.left + seg * segW;
|
||||||
|
// The last segment absorbs the width remainder so the segments tile the whole control.
|
||||||
|
const int right = (seg == kToggleSegments - 1) ? control.right : left + segW;
|
||||||
|
return Rect{left, control.top, right, control.bottom};
|
||||||
|
}
|
||||||
|
|
||||||
|
int toggleSegmentHitTest(const Rect& control, int x, int y) {
|
||||||
|
if (!contains(control, x, y)) return -1;
|
||||||
|
for (int seg = 0; seg < kToggleSegments; ++seg) {
|
||||||
|
if (contains(toggleSegmentRect(control, seg), x, y)) return seg;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rect sliderTrackRect(const Rect& control) {
|
||||||
|
// Inset a half-handle at each end so the handle stays fully inside the control at value
|
||||||
|
// 0 and 1. The handle CENTER ranges across [track.left, track.right].
|
||||||
|
const int half = kSliderHandleWidth / 2;
|
||||||
|
if (control.width() <= kSliderHandleWidth || control.height() <= 0) return Rect{};
|
||||||
|
return Rect{control.left + half, control.top, control.right - half, control.bottom};
|
||||||
|
}
|
||||||
|
|
||||||
|
Rect sliderHandleRect(const Rect& control, double value) {
|
||||||
|
const Rect track = sliderTrackRect(control);
|
||||||
|
if (track.width() <= 0) return Rect{};
|
||||||
|
if (value < 0.0) value = 0.0;
|
||||||
|
if (value > 1.0) value = 1.0;
|
||||||
|
const int span = track.width(); // handle-center movable span
|
||||||
|
const int centerX = track.left + static_cast<int>(value * span + 0.5);
|
||||||
|
const int half = kSliderHandleWidth / 2;
|
||||||
|
return Rect{centerX - half, control.top, centerX - half + kSliderHandleWidth,
|
||||||
|
control.bottom};
|
||||||
|
}
|
||||||
|
|
||||||
|
double valueAtPoint(const Rect& control, int x) {
|
||||||
|
const Rect track = sliderTrackRect(control);
|
||||||
|
const int span = track.width();
|
||||||
|
if (span <= 0) return 0.0;
|
||||||
|
if (x <= track.left) return 0.0;
|
||||||
|
if (x >= track.right) return 1.0;
|
||||||
|
return static_cast<double>(x - track.left) / static_cast<double>(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y) {
|
||||||
|
for (const ControlRow& r : rows) {
|
||||||
|
if (r.kind == ControlKind::Toggle) {
|
||||||
|
if (contains(r.control, x, y)) return r.id;
|
||||||
|
} else { // Slider — the interactive area is the track
|
||||||
|
if (contains(sliderTrackRect(r.control), x, y)) return r.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::vst
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// param_slider.h — PURE control-surface layout + hit-test + value<->pixel mapping for the
|
||||||
|
// S12/S15/S16 editor parameter panel. NO VST3, NO REAPER, NO SWELL/LICE types at the
|
||||||
|
// boundary, and — deliberately — NO sampler_core / sample_map engine types either. The
|
||||||
|
// mirror of keyboard_strip / waveform_view / mode_switch: the fiddly slider-track and
|
||||||
|
// toggle-segment arithmetic lives here, unit-tested outside the DAW, while the editor shell
|
||||||
|
// draws each row (label + track/segments + handle) and routes clicks/drags into these
|
||||||
|
// functions, owning the control-id -> engine-param binding + the value DOMAIN mapping.
|
||||||
|
//
|
||||||
|
// WHY IT EXISTS (S12 + the S15/S16 control surfaces deferred here). The setup / Zones surface
|
||||||
|
// grows a stack of parameter controls: the S15 play-mode toggle (Gate|Trigger), the AHDSR
|
||||||
|
// amp-envelope sliders (attack/hold/decay/sustain/release), the Trigger %-length + fade
|
||||||
|
// controls, the S16 Varispeed|Preserve engine toggle, and the AD pitch-envelope
|
||||||
|
// enable/attack/decay/depth. They are two shapes only — a two-segment TOGGLE and a
|
||||||
|
// horizontal SLIDER — laid out as a vertical stack of fixed-height rows. This module lays out
|
||||||
|
// that stack and maps a slider's NORMALIZED value (0..1) to/from its handle pixel; the shell
|
||||||
|
// converts each control's engine value (frames, seconds, a fraction, a signed semitone
|
||||||
|
// depth) to/from that 0..1 with its own domain knowledge (this module stays engine-free so it
|
||||||
|
// tests without the audio core).
|
||||||
|
//
|
||||||
|
// It reuses editor_geometry's Rect + contains() (one shared geometry idiom).
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "editor_geometry.h" // Rect, contains — one shared geometry idiom
|
||||||
|
|
||||||
|
namespace reasampler::vst {
|
||||||
|
|
||||||
|
// Fixed control-panel metrics, exposed so the shell and tests agree.
|
||||||
|
inline constexpr int kControlRowHeight = 22; // one control row (incl. its inter-row gap)
|
||||||
|
inline constexpr int kControlRowGap = 4; // vertical gap below each row
|
||||||
|
inline constexpr int kControlLabelWidth = 92; // the label column at the row's left
|
||||||
|
inline constexpr int kSliderHandleWidth = 8; // the draggable slider handle width (px)
|
||||||
|
inline constexpr int kToggleSegments = 2; // a toggle is always two segments
|
||||||
|
|
||||||
|
// A control is one of two shapes. Toggle = a two-segment selector (the active segment
|
||||||
|
// highlights); Slider = a horizontal track with a draggable handle over a 0..1 value.
|
||||||
|
enum class ControlKind { Toggle, Slider };
|
||||||
|
|
||||||
|
// One control the shell places in the panel, in stack order. `id` is the shell's own control
|
||||||
|
// identifier (an int the shell casts from its ControlId enum) returned by the hit-test so the
|
||||||
|
// shell routes the interaction to the right engine param — this module never interprets it.
|
||||||
|
struct ControlDesc {
|
||||||
|
int id = 0;
|
||||||
|
ControlKind kind = ControlKind::Slider;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The laid-out geometry of one control row: its full row rect plus the interactive sub-rect
|
||||||
|
// (the track for a Slider, the whole control area for a Toggle — the shell splits a Toggle
|
||||||
|
// into segments via toggleSegmentRect). `index` is the control's position in the stack.
|
||||||
|
struct ControlRow {
|
||||||
|
int id = 0;
|
||||||
|
ControlKind kind = ControlKind::Slider;
|
||||||
|
Rect row; // the full row (label column + control column)
|
||||||
|
Rect label; // the label column at the left
|
||||||
|
Rect control; // the control column to the right of the label (track / toggle area)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lay out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down. Each
|
||||||
|
// row is kControlRowHeight tall with kControlRowGap below it; the label column takes the left
|
||||||
|
// kControlLabelWidth (clamped so it never exceeds the panel), the control column the rest. A
|
||||||
|
// row whose top falls past the panel bottom is still returned (the shell clips at paint /
|
||||||
|
// suppresses it) so the stack geometry is deterministic regardless of panel height. An empty
|
||||||
|
// control list or a degenerate panel yields an empty vector. Pure.
|
||||||
|
std::vector<ControlRow> layoutControls(const Rect& panel,
|
||||||
|
const std::vector<ControlDesc>& controls);
|
||||||
|
|
||||||
|
// The rect of segment `seg` (0..kToggleSegments-1) within a toggle control's `control` rect,
|
||||||
|
// splitting it into kToggleSegments equal segments left-to-right (the last absorbs any width
|
||||||
|
// remainder, mirror of mode_switch's segment split). An out-of-range segment or a degenerate
|
||||||
|
// control rect yields an empty rect. Pure.
|
||||||
|
Rect toggleSegmentRect(const Rect& control, int seg);
|
||||||
|
|
||||||
|
// The toggle segment a point lands on within a toggle control's `control` rect, or -1 for a
|
||||||
|
// miss (outside the control area). Pure.
|
||||||
|
int toggleSegmentHitTest(const Rect& control, int x, int y);
|
||||||
|
|
||||||
|
// The slider track sub-rect inside a slider control's `control` rect: the control inset so the
|
||||||
|
// handle (kSliderHandleWidth) stays fully within the control at value 0 and 1 (a half-handle
|
||||||
|
// margin at each end). The handle CENTER ranges across [track.left, track.right] as the value
|
||||||
|
// ranges [0,1]. The shell draws the track fill + handle here. A degenerate control yields an
|
||||||
|
// empty rect. Pure.
|
||||||
|
Rect sliderTrackRect(const Rect& control);
|
||||||
|
|
||||||
|
// The handle rect for a slider at normalized `value` (clamped to [0,1]) within `control`: a
|
||||||
|
// kSliderHandleWidth-wide bar centered at the value's position along sliderTrackRect. A
|
||||||
|
// degenerate control yields an empty rect. Pure — the inverse of valueAtPoint.
|
||||||
|
Rect sliderHandleRect(const Rect& control, double value);
|
||||||
|
|
||||||
|
// Map a point x to a normalized slider value [0,1] within `control` (the handle-center range).
|
||||||
|
// x at/left of the track start -> 0; at/right of the end -> 1; linear between. A degenerate
|
||||||
|
// track (zero movable span) -> 0. Pure — the inverse of sliderHandleRect's position map; the
|
||||||
|
// shell converts the returned 0..1 into its engine domain (frames/seconds/fraction/semitones).
|
||||||
|
double valueAtPoint(const Rect& control, int x);
|
||||||
|
|
||||||
|
// The control a point lands on, given the laid-out `rows`. Returns the control id (ControlDesc
|
||||||
|
// id) whose interactive area (a Slider's track, a Toggle's whole control area) contains the
|
||||||
|
// point, or -1 for a miss (a gap, the label column, or outside every row). The FIRST matching
|
||||||
|
// row wins (rows never overlap, so at most one matches). Pure — the shell's routing entry
|
||||||
|
// point: on a hit it reads the value (valueAtPoint / toggleSegmentHitTest) and commits.
|
||||||
|
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y);
|
||||||
|
|
||||||
|
} // namespace reasampler::vst
|
||||||
+538
-37
@@ -10,11 +10,14 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "browser_scroll.h" // S12 scroll-window + thumb + type-to-filter search geometry
|
||||||
#include "capture_browser.h"
|
#include "capture_browser.h"
|
||||||
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution)
|
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution)
|
||||||
#include "editor_geometry.h" // Rect, contains
|
#include "editor_geometry.h" // Rect, contains
|
||||||
#include "ext_keys.h"
|
#include "ext_keys.h"
|
||||||
#include "keyboard_strip.h"
|
#include "keyboard_strip.h"
|
||||||
|
#include "note_entry.h" // S12 direct numeric note-entry parse
|
||||||
|
#include "param_slider.h" // S12/S15/S16 control-surface layout + value<->pixel mapping
|
||||||
#include "peaks.h" // computeEnvelope
|
#include "peaks.h" // computeEnvelope
|
||||||
#include "reaper_bridge.h"
|
#include "reaper_bridge.h"
|
||||||
#include "reasampler_processor.h"
|
#include "reasampler_processor.h"
|
||||||
@@ -175,11 +178,18 @@ void ReaSamplerEditor::refreshFromBank() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerEditor::rebuildVisible() {
|
void ReaSamplerEditor::rebuildVisible() {
|
||||||
|
// S12 composition: the bank filter picks the bank FIRST, then the type-to-filter search
|
||||||
|
// narrows the survivors by name substring (nameMatchesQuery — empty query is the identity).
|
||||||
visible_.clear();
|
visible_.clear();
|
||||||
for (const SampleChoice& s : samples_) {
|
for (const SampleChoice& s : samples_) {
|
||||||
if (activeFilterBankId_.empty() || s.bankId == activeFilterBankId_)
|
const bool inBank = activeFilterBankId_.empty() || s.bankId == activeFilterBankId_;
|
||||||
visible_.push_back(s);
|
if (!inBank) continue;
|
||||||
|
const std::string& name = s.displayName.empty() ? s.id : s.displayName;
|
||||||
|
if (nameMatchesQuery(name, searchQuery_)) visible_.push_back(s);
|
||||||
}
|
}
|
||||||
|
// NOTE: scrollOffset_ is clamped at paint + wheel time (where the browser layout / panel
|
||||||
|
// height is known); rebuildVisible runs cross-platform + on the sync-timer refresh, so it
|
||||||
|
// must not reset the user's scroll here.
|
||||||
}
|
}
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
@@ -259,31 +269,133 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) {
|
int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) {
|
||||||
// Find-or-append the zone for selectedId_ and write the loop/start override fields.
|
// Find-or-append the zone for selectedId_ and write the loop/start override fields.
|
||||||
// The bank intrinsic is NEVER written (read-only bank consumer, D-B). selectedId_ must
|
// The bank intrinsic is NEVER written (read-only bank consumer, D-B). selectedId_ must
|
||||||
// be non-empty; callers are responsible for that guard.
|
// be non-empty; callers are responsible for that guard.
|
||||||
|
// Returns the zone index (0-based) so callers can update selectedZone_.
|
||||||
SampleLoop loop;
|
SampleLoop loop;
|
||||||
loop.hasLoop = m.hasLoop;
|
loop.hasLoop = m.hasLoop;
|
||||||
loop.start = m.loopStart;
|
loop.start = m.loopStart;
|
||||||
loop.end = m.loopEnd;
|
loop.end = m.loopEnd;
|
||||||
bool found = false;
|
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
||||||
for (PerformanceZone& z : map_.zones) {
|
PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
|
||||||
if (z.sampleId == selectedId_) {
|
if (z.sampleId == selectedId_) {
|
||||||
z.loopOverride = loop;
|
z.loopOverride = loop;
|
||||||
z.startPoint = m.start;
|
z.startPoint = m.start;
|
||||||
found = true;
|
return i;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!found) {
|
PerformanceZone z;
|
||||||
PerformanceZone z;
|
z.sampleId = selectedId_;
|
||||||
z.sampleId = selectedId_;
|
z.lowNote = 0;
|
||||||
z.lowNote = 0;
|
z.highNote = 127;
|
||||||
z.highNote = 127;
|
z.loopOverride = loop;
|
||||||
z.loopOverride = loop;
|
z.startPoint = m.start;
|
||||||
z.startPoint = m.start;
|
map_.zones.push_back(z);
|
||||||
map_.zones.push_back(z);
|
return static_cast<int>(map_.zones.size()) - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is
|
||||||
|
// engine-free and maps only 0..1). WALL-CLOCK time sliders (AHDSR A/H/D/R, pitch env A/D) span
|
||||||
|
// [0, kEnvTimeMaxSeconds] SECONDS — rate-free, exactly what the zone stores; the keymap build
|
||||||
|
// resolves seconds->frames at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out)
|
||||||
|
// span [0, kFadeMaxFrames] SOURCE frames (a source-timeline quantity, PLAN.md §S15 — never a
|
||||||
|
// wall-clock second). Build-time residual — one place to retune; not persisted.
|
||||||
|
constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds)
|
||||||
|
constexpr double kFadeMaxFrames = 88200.0; // Trigger fade throw ceiling (source frames)
|
||||||
|
constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered
|
||||||
|
|
||||||
|
double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::vector<ControlDesc> ReaSamplerEditor::controlDescs(const ZonePlaySeconds& play) const {
|
||||||
|
std::vector<ControlDesc> out;
|
||||||
|
// Always: the two mode toggles.
|
||||||
|
out.push_back({static_cast<int>(ParamControl::kPlayMode), ControlKind::Toggle});
|
||||||
|
out.push_back({static_cast<int>(ParamControl::kPitchEngine), ControlKind::Toggle});
|
||||||
|
// Mode-relevant amplitude sliders.
|
||||||
|
if (play.playMode == PlayMode::Gate) {
|
||||||
|
out.push_back({static_cast<int>(ParamControl::kAttack), ControlKind::Slider});
|
||||||
|
out.push_back({static_cast<int>(ParamControl::kHold), ControlKind::Slider});
|
||||||
|
out.push_back({static_cast<int>(ParamControl::kDecay), ControlKind::Slider});
|
||||||
|
out.push_back({static_cast<int>(ParamControl::kSustain), ControlKind::Slider});
|
||||||
|
out.push_back({static_cast<int>(ParamControl::kRelease), ControlKind::Slider});
|
||||||
|
} else { // Trigger
|
||||||
|
out.push_back({static_cast<int>(ParamControl::kTrigLength), ControlKind::Slider});
|
||||||
|
out.push_back({static_cast<int>(ParamControl::kTrigFadeIn), ControlKind::Slider});
|
||||||
|
out.push_back({static_cast<int>(ParamControl::kTrigFadeOut), ControlKind::Slider});
|
||||||
|
}
|
||||||
|
// The AD pitch envelope: an enable toggle + its three sliders (drawn always; inert until on).
|
||||||
|
out.push_back({static_cast<int>(ParamControl::kPitchEnvEnable), ControlKind::Toggle});
|
||||||
|
out.push_back({static_cast<int>(ParamControl::kPitchEnvAttack), ControlKind::Slider});
|
||||||
|
out.push_back({static_cast<int>(ParamControl::kPitchEnvDecay), ControlKind::Slider});
|
||||||
|
out.push_back({static_cast<int>(ParamControl::kPitchEnvDepth), ControlKind::Slider});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const {
|
||||||
|
// Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over
|
||||||
|
// the frames ceiling. Two domains, kept explicit so neither leaks a rate.
|
||||||
|
const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); };
|
||||||
|
const auto framesToNorm = [](std::int64_t f) {
|
||||||
|
return clamp01(static_cast<double>(f) / kFadeMaxFrames);
|
||||||
|
};
|
||||||
|
switch (static_cast<ParamControl>(id)) {
|
||||||
|
case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
|
||||||
|
case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0;
|
||||||
|
case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds);
|
||||||
|
case ParamControl::kHold: return secToNorm(play.adsr.holdSeconds);
|
||||||
|
case ParamControl::kDecay: return secToNorm(play.adsr.decaySeconds);
|
||||||
|
case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel);
|
||||||
|
case ParamControl::kRelease: return secToNorm(play.adsr.releaseSeconds);
|
||||||
|
case ParamControl::kTrigLength: return clamp01(play.trigger.lengthFraction);
|
||||||
|
case ParamControl::kTrigFadeIn: return framesToNorm(play.trigger.fadeInFrames);
|
||||||
|
case ParamControl::kTrigFadeOut: return framesToNorm(play.trigger.fadeOutFrames);
|
||||||
|
case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0;
|
||||||
|
case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.attackSeconds);
|
||||||
|
case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.decaySeconds);
|
||||||
|
case ParamControl::kPitchEnvDepth:
|
||||||
|
// Signed depth centered at 0.5 (0.5 == 0 semitones).
|
||||||
|
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
|
||||||
|
default: return 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value,
|
||||||
|
int segment) const {
|
||||||
|
const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
|
||||||
|
const auto normToFrames = [](double v) {
|
||||||
|
return static_cast<std::int64_t>(clamp01(v) * kFadeMaxFrames + 0.5);
|
||||||
|
};
|
||||||
|
switch (static_cast<ParamControl>(id)) {
|
||||||
|
case ParamControl::kPlayMode:
|
||||||
|
play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate;
|
||||||
|
break;
|
||||||
|
case ParamControl::kPitchEngine:
|
||||||
|
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||||
|
break;
|
||||||
|
case ParamControl::kAttack: play.adsr.attackSeconds = normToSec(value); break;
|
||||||
|
case ParamControl::kHold: play.adsr.holdSeconds = normToSec(value); break;
|
||||||
|
case ParamControl::kDecay: play.adsr.decaySeconds = normToSec(value); break;
|
||||||
|
case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break;
|
||||||
|
case ParamControl::kRelease: play.adsr.releaseSeconds = normToSec(value); break;
|
||||||
|
case ParamControl::kTrigLength:
|
||||||
|
// lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays nothing.
|
||||||
|
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
|
||||||
|
break;
|
||||||
|
case ParamControl::kTrigFadeIn: play.trigger.fadeInFrames = normToFrames(value); break;
|
||||||
|
case ParamControl::kTrigFadeOut: play.trigger.fadeOutFrames = normToFrames(value); break;
|
||||||
|
case ParamControl::kPitchEnvEnable:
|
||||||
|
play.pitchEnv.enabled = (segment == 1);
|
||||||
|
break;
|
||||||
|
case ParamControl::kPitchEnvAttack: play.pitchEnv.attackSeconds = normToSec(value); break;
|
||||||
|
case ParamControl::kPitchEnvDecay: play.pitchEnv.decaySeconds = normToSec(value); break;
|
||||||
|
case ParamControl::kPitchEnvDepth:
|
||||||
|
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
|
||||||
|
break;
|
||||||
|
default: break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,6 +604,37 @@ Rect zonesStripArea(const EditorBands& bands) {
|
|||||||
stripTop + kStripBandHeight};
|
stripTop + kStripBandHeight};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The S12 numeric-entry field ROW area inside the Zones legend: a band to the right of the
|
||||||
|
// sample label on the legend row. Three equal fields (low/high/root) tile it. Both draw +
|
||||||
|
// hit-test use this single formula so they never drift. Anchored off zonesStripArea.bottom so
|
||||||
|
// the legend top tracks the strip bottom without re-inlining the strip arithmetic here.
|
||||||
|
Rect noteEntryFieldsArea(const EditorBands& bands) {
|
||||||
|
const int stripBottom = zonesStripArea(bands).bottom;
|
||||||
|
const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom + 8)
|
||||||
|
return Rect{bands.content.left + 8 + 128, top, bands.content.right - 8, top + 18};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rect of note-entry field `f` (0=low, 1=high, 2=root) within the fields area: three equal
|
||||||
|
// segments left-to-right. An out-of-range index yields an empty rect.
|
||||||
|
Rect noteEntryFieldRect(const Rect& fields, int f) {
|
||||||
|
if (f < 0 || f > 2 || fields.width() <= 0) return Rect{};
|
||||||
|
const int segW = fields.width() / 3;
|
||||||
|
const int left = fields.left + f * segW + (f > 0 ? 4 : 0); // small inter-field gap
|
||||||
|
const int right = (f == 2) ? fields.right : fields.left + (f + 1) * segW;
|
||||||
|
return Rect{left, fields.top, right, fields.bottom};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The S12/S15/S16 parameter-control panel rect inside the Zones content: below the strip +
|
||||||
|
// the one-line selected-zone legend, running to the content bottom. `bands.content` is the
|
||||||
|
// Zones mode-content area. Both draw + hit-test use this single formula so they never drift.
|
||||||
|
Rect zonesControlPanel(const EditorBands& bands) {
|
||||||
|
constexpr int pad = 8;
|
||||||
|
const Rect strip = zonesStripArea(bands);
|
||||||
|
const int panelTop = strip.bottom + 8 + 18 + 8; // strip + the 18px legend row + gap
|
||||||
|
return Rect{bands.content.left + pad, panelTop, bands.content.right - pad,
|
||||||
|
bands.content.bottom - 4};
|
||||||
|
}
|
||||||
|
|
||||||
// The S7 mono/stereo toggle, a two-segment control anchored to the RIGHT of the setup band's
|
// The S7 mono/stereo toggle, a two-segment control anchored to the RIGHT of the setup band's
|
||||||
// header row (same y as the sample-name header, so it reads as "this capture's output mode").
|
// header row (same y as the sample-name header, so it reads as "this capture's output mode").
|
||||||
// `area` is the full setup Rect. Returns {mono-segment, stereo-segment}; each is kSegW wide,
|
// `area` is the full setup Rect. Returns {mono-segment, stereo-segment}; each is kSegW wide,
|
||||||
@@ -572,13 +715,31 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
|
|||||||
const bool havePick = !selectedId_.empty();
|
const bool havePick = !selectedId_.empty();
|
||||||
const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
|
const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
|
||||||
: bands.content.bottom;
|
: bands.content.bottom;
|
||||||
const Rect browserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
|
const Rect fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
|
||||||
|
|
||||||
|
// S12: reserve a type-to-filter search box at the top of the browser area; the tabs + grid
|
||||||
|
// sit below it. The search box spans the browser width.
|
||||||
|
const Rect searchBox = searchBoxRect(fullBrowserArea.width());
|
||||||
|
const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top,
|
||||||
|
fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom};
|
||||||
|
LICE_FillRect(bmp, searchAbs.left, searchAbs.top, searchAbs.width(), searchAbs.height(),
|
||||||
|
searchFocused_ ? kColTabActiveBg : kColTabBg, 1.0f, 0);
|
||||||
|
{
|
||||||
|
std::string sb = searchQuery_.empty()
|
||||||
|
? std::string("Search captures...")
|
||||||
|
: ("Search: " + searchQuery_ + (searchFocused_ ? "_" : ""));
|
||||||
|
Rect sbText{searchAbs.left + 6, searchAbs.top, searchAbs.right - 6, searchAbs.bottom};
|
||||||
|
drawText(bmp, sbText, sb.c_str(), searchQuery_.empty() ? kRgbDim : kRgbText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop};
|
||||||
|
|
||||||
// The browser tabs + card grid, laid out by the pure module over the browser sub-area.
|
// 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.
|
// capture_browser lays out from (0,0); offset the draw by browserArea's origin.
|
||||||
const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height());
|
const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height());
|
||||||
const int ox = browserArea.left;
|
const int ox = browserArea.left;
|
||||||
const int oy = browserArea.top;
|
const int oy = browserArea.top;
|
||||||
|
scrollOffset_ = clampScrollOffset(bl, static_cast<int>(visible_.size()), scrollOffset_);
|
||||||
|
|
||||||
// Filter tabs: an "All" tab (index 0) + one per named bank. The active tab highlights.
|
// Filter tabs: an "All" tab (index 0) + one per named bank. The active tab highlights.
|
||||||
const int tabCount = static_cast<int>(banks_.size()) + 1;
|
const int tabCount = static_cast<int>(banks_.size()) + 1;
|
||||||
@@ -593,16 +754,24 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
|
|||||||
drawTextCentered(bmp, t, label.c_str(), kRgbText);
|
drawTextCentered(bmp, t, label.c_str(), kRgbText);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cards: one per visible sample. Clip at the browser area bottom (scroll is S12).
|
// Cards: only the S12 visible window at the current scroll offset (a bank longer than the
|
||||||
|
// panel is reachable by wheel/thumb drag). scrolledCardCellRect shifts each cell up by the
|
||||||
|
// offset; we clip to the grid region so a partially-scrolled row is trimmed at the edges.
|
||||||
const int bins = thumbBins(bl);
|
const int bins = thumbBins(bl);
|
||||||
for (int i = 0; i < static_cast<int>(visible_.size()); ++i) {
|
const int cardCount = static_cast<int>(visible_.size());
|
||||||
|
const VisibleRange vr = visibleCardRange(bl, cardCount, scrollOffset_);
|
||||||
|
for (int i = vr.first; i < vr.last; ++i) {
|
||||||
|
// The scrolled CELL, then the same gutter/thumbnail/label insets the pure module derives,
|
||||||
|
// shifted by the scroll offset (they share the cell's top, so subtract the offset).
|
||||||
Rect content = cardContentRect(bl, i);
|
Rect content = cardContentRect(bl, i);
|
||||||
if (content.top + oy >= browserArea.bottom) break; // past the visible grid
|
|
||||||
Rect thumb = cardThumbnailRect(bl, i);
|
Rect thumb = cardThumbnailRect(bl, i);
|
||||||
Rect labelR = cardLabelRect(bl, i);
|
Rect labelR = cardLabelRect(bl, i);
|
||||||
content = Rect{content.left + ox, content.top + oy, content.right + ox, content.bottom + oy};
|
content = Rect{content.left + ox, content.top + oy - scrollOffset_,
|
||||||
thumb = Rect{thumb.left + ox, thumb.top + oy, thumb.right + ox, thumb.bottom + oy};
|
content.right + ox, content.bottom + oy - scrollOffset_};
|
||||||
labelR = Rect{labelR.left + ox, labelR.top + oy, labelR.right + ox, labelR.bottom + oy};
|
thumb = Rect{thumb.left + ox, thumb.top + oy - scrollOffset_,
|
||||||
|
thumb.right + ox, thumb.bottom + oy - scrollOffset_};
|
||||||
|
labelR = Rect{labelR.left + ox, labelR.top + oy - scrollOffset_,
|
||||||
|
labelR.right + ox, labelR.bottom + oy - scrollOffset_};
|
||||||
|
|
||||||
const SampleChoice& s = visible_[static_cast<std::size_t>(i)];
|
const SampleChoice& s = visible_[static_cast<std::size_t>(i)];
|
||||||
const bool sel = (s.id == selectedId_);
|
const bool sel = (s.id == selectedId_);
|
||||||
@@ -624,6 +793,17 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
|
|||||||
drawText(bmp, badgeR, badge.c_str(), kRgbDim);
|
drawText(bmp, badgeR, badge.c_str(), kRgbDim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// S12 scrollbar: a thumb in the grid's right-edge gutter, sized/positioned by the pure
|
||||||
|
// module (empty when the content fits — the shell simply draws nothing then). Offset by the
|
||||||
|
// browser origin like every other card rect.
|
||||||
|
{
|
||||||
|
const Rect thumb = scrollThumbRect(bl, cardCount, scrollOffset_);
|
||||||
|
if (thumb.height() > 0) {
|
||||||
|
LICE_FillRect(bmp, thumb.left + ox, thumb.top + oy, thumb.width(), thumb.height(),
|
||||||
|
kColRootMarker, 0.8f, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (havePick) {
|
if (havePick) {
|
||||||
paintSetup(bmp, Rect{bands.content.left, setupTop, bands.content.right, bands.content.bottom});
|
paintSetup(bmp, Rect{bands.content.left, setupTop, bands.content.right, bands.content.bottom});
|
||||||
} else if (visible_.empty()) {
|
} else if (visible_.empty()) {
|
||||||
@@ -769,18 +949,115 @@ void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) {
|
|||||||
sel ? kColZoneBarSel : kColZoneBar, sel ? 1.0f : 0.7f, 0);
|
sel ? kColZoneBarSel : kColZoneBar, sel ? 1.0f : 0.7f, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A one-line legend of the selected zone below the strip.
|
// A one-line legend of the selected zone below the strip, with three click-to-type numeric
|
||||||
Rect infoR{stripArea.left, stripArea.bottom + 8, stripArea.right, stripArea.bottom + 26};
|
// entry fields (low / high / root) — S12 direct numeric entry. Clicking a field focuses it
|
||||||
|
// (entryField_) and typed text commits via parseNoteEntry on Enter.
|
||||||
|
const int legendTop = stripArea.bottom + 8;
|
||||||
|
Rect infoR{stripArea.left, legendTop, stripArea.right, legendTop + 18};
|
||||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||||
std::string info = sampleLabel(samples_, z.sampleId) + " " + noteLabel(z.lowNote) +
|
drawText(bmp, Rect{infoR.left, infoR.top, infoR.left + 120, infoR.bottom},
|
||||||
" - " + noteLabel(z.highNote) + " root " +
|
sampleLabel(samples_, z.sampleId).c_str(), kRgbText);
|
||||||
(z.rootOverride ? noteLabel(*z.rootOverride) + "*" : std::string("(bank)"));
|
// Three fields laid out left-to-right after the sample label.
|
||||||
drawText(bmp, infoR, info.c_str(), kRgbText);
|
const Rect fields = noteEntryFieldsArea(bands);
|
||||||
|
const char* names[3] = {"Low", "High", "Root"};
|
||||||
|
const std::string vals[3] = {
|
||||||
|
noteLabel(z.lowNote), noteLabel(z.highNote),
|
||||||
|
z.rootOverride ? noteLabel(*z.rootOverride) : std::string("(bank)")};
|
||||||
|
for (int f = 0; f < 3; ++f) {
|
||||||
|
const Rect fr = noteEntryFieldRect(fields, f);
|
||||||
|
const bool editing = (entryField_ == f);
|
||||||
|
LICE_FillRect(bmp, fr.left, fr.top, fr.width(), fr.height(),
|
||||||
|
editing ? kColTabActiveBg : kColCardBg, 1.0f, 0);
|
||||||
|
LICE_DrawRect(bmp, fr.left, fr.top, fr.width() - 1, fr.height() - 1,
|
||||||
|
kColCardBorder, 1.0f, 0);
|
||||||
|
std::string cap = std::string(names[f]) + ": " +
|
||||||
|
(editing ? (entryText_ + "_") : vals[f]);
|
||||||
|
drawText(bmp, Rect{fr.left + 4, fr.top, fr.right - 2, fr.bottom}, cap.c_str(), kRgbText);
|
||||||
|
}
|
||||||
} else if (map_.zones.empty()) {
|
} else if (map_.zones.empty()) {
|
||||||
drawText(bmp, infoR,
|
drawText(bmp, infoR,
|
||||||
"No zones. Add Zone maps the picked capture across the keyboard.", kRgbDim);
|
"No zones. Add Zone maps the picked capture across the keyboard.", kRgbDim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The S12/S15/S16 parameter surface for the selected zone (play mode + AHDSR / Trigger +
|
||||||
|
// pitch engine + AD pitch envelope). Shown for an explicit zone selection OR for the
|
||||||
|
// single-capture face when the map is empty but a capture is picked (S15-F2 lean: the
|
||||||
|
// single capture is already a one-zone map — one storage site serves both).
|
||||||
|
const bool haveControlTarget =
|
||||||
|
(selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) ||
|
||||||
|
(map_.zones.empty() && !selectedId_.empty());
|
||||||
|
if (haveControlTarget) {
|
||||||
|
paintControls(bmp, zonesControlPanel(computeBands(w, h)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The label + the two toggle-segment captions for a control (member so it can name the private
|
||||||
|
// ParamControl enum). Segments are only read for a ControlKind::Toggle.
|
||||||
|
namespace {
|
||||||
|
struct ControlLabels { const char* label; const char* seg0; const char* seg1; };
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void ReaSamplerEditor::paintControls(LICE_IBitmap* bmp, const Rect& panel) {
|
||||||
|
// Resolve the play params: from the selected zone when one is chosen, or from the
|
||||||
|
// PerformanceZone product defaults when the map is empty but a capture is picked
|
||||||
|
// (S15-F2 lean: the single-capture face shares the same storage site as a one-zone map;
|
||||||
|
// see paintZones for the gate that reaches here).
|
||||||
|
ZonePlaySeconds play;
|
||||||
|
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||||
|
play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
|
||||||
|
} else if (map_.zones.empty() && !selectedId_.empty()) {
|
||||||
|
play = PerformanceZone{}.play; // product defaults (Gate + Preserve + tier-0 ADSR)
|
||||||
|
} else {
|
||||||
|
return; // no control target
|
||||||
|
}
|
||||||
|
const std::vector<ControlDesc> descs = controlDescs(play);
|
||||||
|
const std::vector<ControlRow> rows = layoutControls(panel, descs);
|
||||||
|
|
||||||
|
const auto labelsFor = [](ParamControl c) -> ControlLabels {
|
||||||
|
switch (c) {
|
||||||
|
case ParamControl::kPlayMode: return {"Mode", "Gate", "Trigger"};
|
||||||
|
case ParamControl::kPitchEngine: return {"Pitch eng", "Varisp", "Preserve"};
|
||||||
|
case ParamControl::kAttack: return {"Attack", "", ""};
|
||||||
|
case ParamControl::kHold: return {"Hold", "", ""};
|
||||||
|
case ParamControl::kDecay: return {"Decay", "", ""};
|
||||||
|
case ParamControl::kSustain: return {"Sustain", "", ""};
|
||||||
|
case ParamControl::kRelease: return {"Release", "", ""};
|
||||||
|
case ParamControl::kTrigLength: return {"Length %", "", ""};
|
||||||
|
case ParamControl::kTrigFadeIn: return {"Fade in", "", ""};
|
||||||
|
case ParamControl::kTrigFadeOut: return {"Fade out", "", ""};
|
||||||
|
case ParamControl::kPitchEnvEnable: return {"Pitch env", "Off", "On"};
|
||||||
|
case ParamControl::kPitchEnvAttack: return {"P.Attack", "", ""};
|
||||||
|
case ParamControl::kPitchEnvDecay: return {"P.Decay", "", ""};
|
||||||
|
case ParamControl::kPitchEnvDepth: return {"P.Depth", "", ""};
|
||||||
|
default: return {"", "", ""};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const ControlRow& r : rows) {
|
||||||
|
if (r.row.top >= panel.bottom) break; // clip at the panel bottom
|
||||||
|
const ControlLabels lab = labelsFor(static_cast<ParamControl>(r.id));
|
||||||
|
drawText(bmp, r.label, lab.label, kRgbDim);
|
||||||
|
const double v = controlValue(r.id, play);
|
||||||
|
if (r.kind == ControlKind::Toggle) {
|
||||||
|
const bool seg1 = (v >= 0.5);
|
||||||
|
const Rect s0 = toggleSegmentRect(r.control, 0);
|
||||||
|
const Rect s1 = toggleSegmentRect(r.control, 1);
|
||||||
|
LICE_FillRect(bmp, s0.left, s0.top, s0.width(), s0.height(),
|
||||||
|
seg1 ? kColTabBg : kColTabActiveBg, 1.0f, 0);
|
||||||
|
LICE_FillRect(bmp, s1.left, s1.top, s1.width(), s1.height(),
|
||||||
|
seg1 ? kColTabActiveBg : kColTabBg, 1.0f, 0);
|
||||||
|
drawTextCentered(bmp, s0, lab.seg0, kRgbText);
|
||||||
|
drawTextCentered(bmp, s1, lab.seg1, kRgbText);
|
||||||
|
} else {
|
||||||
|
const Rect track = sliderTrackRect(r.control);
|
||||||
|
LICE_FillRect(bmp, track.left, track.top + track.height() / 2 - 1, track.width(), 2,
|
||||||
|
kColStripKey, 1.0f, 0);
|
||||||
|
const Rect handle = sliderHandleRect(r.control, v);
|
||||||
|
LICE_FillRect(bmp, handle.left, handle.top + 2, handle.width(), handle.height() - 4,
|
||||||
|
kColRootMarker, 1.0f, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Input: the drag-state machine -------------------------------------------
|
// --- Input: the drag-state machine -------------------------------------------
|
||||||
@@ -801,7 +1078,20 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
|||||||
const bool havePick = !selectedId_.empty();
|
const bool havePick = !selectedId_.empty();
|
||||||
const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
|
const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
|
||||||
: bands.content.bottom;
|
: bands.content.bottom;
|
||||||
const Rect browserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
|
const Rect fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
|
||||||
|
|
||||||
|
// S12 search box (mirror of paintBrowser): a click focuses it; the browser sits below.
|
||||||
|
const Rect searchBox = searchBoxRect(fullBrowserArea.width());
|
||||||
|
const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top,
|
||||||
|
fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom};
|
||||||
|
if (contains(searchAbs, x, y)) {
|
||||||
|
searchFocused_ = true;
|
||||||
|
invalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchFocused_ = false; // any other browser click defocuses the search box
|
||||||
|
|
||||||
|
const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop};
|
||||||
const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height());
|
const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height());
|
||||||
const int bx = x - browserArea.left;
|
const int bx = x - browserArea.left;
|
||||||
const int by = y - browserArea.top;
|
const int by = y - browserArea.top;
|
||||||
@@ -816,8 +1106,20 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
|||||||
invalidate();
|
invalidate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Cards: pick a capture -> load it (this is the whole time-to-first-note gesture).
|
// S12 scrollbar thumb: grab to drag-scroll (checked before cards — the thumb overlays the
|
||||||
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by);
|
// grid's right gutter). scrollThumbRect is empty when the content fits, so this is inert then.
|
||||||
|
const Rect thumb = scrollThumbRect(bl, static_cast<int>(visible_.size()), scrollOffset_);
|
||||||
|
if (thumb.height() > 0 &&
|
||||||
|
contains(Rect{thumb.left + browserArea.left, thumb.top + browserArea.top,
|
||||||
|
thumb.right + browserArea.left, thumb.bottom + browserArea.top}, x, y)) {
|
||||||
|
drag_ = DragKind::kScrollThumb;
|
||||||
|
dragStartY_ = y;
|
||||||
|
dragStartScrollOffset_ = scrollOffset_;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Cards: pick a capture -> load it (this is the whole time-to-first-note gesture). The
|
||||||
|
// hit-test adds the scroll offset back so a scrolled card maps to the right index.
|
||||||
|
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
|
||||||
if (card >= 0) {
|
if (card >= 0) {
|
||||||
selectedId_ = visible_[static_cast<std::size_t>(card)].id;
|
selectedId_ = visible_[static_cast<std::size_t>(card)].id;
|
||||||
commitAndReload(); // publishes the pick + reloads; process() plays it repitched
|
commitAndReload(); // publishes the pick + reloads; process() plays it repitched
|
||||||
@@ -958,6 +1260,76 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
|||||||
map_.zones[static_cast<std::size_t>(selectedZone_)].rootOverride = note;
|
map_.zones[static_cast<std::size_t>(selectedZone_)].rootOverride = note;
|
||||||
commitAndReload();
|
commitAndReload();
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// S12 numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a
|
||||||
|
// zone is selected. entryText_ starts empty (the user types the full value).
|
||||||
|
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||||
|
const Rect fields = noteEntryFieldsArea(bands);
|
||||||
|
for (int f = 0; f < 3; ++f) {
|
||||||
|
if (contains(noteEntryFieldRect(fields, f), x, y)) {
|
||||||
|
entryField_ = f;
|
||||||
|
entryText_.clear();
|
||||||
|
invalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entryField_ = -1; // a click elsewhere in the Zones view cancels an in-progress entry
|
||||||
|
|
||||||
|
// The S12/S15/S16 parameter panel: a toggle segment flips at once (commit); a slider grab
|
||||||
|
// starts a live drag (commit on release). Reachable for an explicit zone selection OR for
|
||||||
|
// the single-capture face when the map is empty but a capture is picked (S15-F2 lean).
|
||||||
|
// In the empty-map+picked case, auto-create a full-keyboard zone for selectedId_ on first
|
||||||
|
// control interaction (same path as "+ Add Zone"), then apply the control — the zone is
|
||||||
|
// committed as part of the control edit.
|
||||||
|
if (selectedZone_ < 0 && map_.zones.empty() && !selectedId_.empty()) {
|
||||||
|
// Synthesize a probe layout with the product defaults to see if the click is in the
|
||||||
|
// panel before committing to creating the zone.
|
||||||
|
const Rect panel = zonesControlPanel(bands);
|
||||||
|
const ZonePlaySeconds defaultPlay = PerformanceZone{}.play;
|
||||||
|
const std::vector<ControlDesc> probeDescs = controlDescs(defaultPlay);
|
||||||
|
const std::vector<ControlRow> probeRows = layoutControls(panel, probeDescs);
|
||||||
|
if (controlAtPoint(probeRows, x, y) >= 0) {
|
||||||
|
// The click lands in the control panel — materialize the zone now.
|
||||||
|
PerformanceZone z;
|
||||||
|
z.sampleId = selectedId_;
|
||||||
|
z.lowNote = 0;
|
||||||
|
z.highNote = 127;
|
||||||
|
map_.zones.push_back(z);
|
||||||
|
selectedZone_ = 0;
|
||||||
|
// Fall through to the control handler below which will process the click.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||||
|
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||||
|
const Rect panel = zonesControlPanel(bands);
|
||||||
|
const std::vector<ControlDesc> descs = controlDescs(z.play);
|
||||||
|
const std::vector<ControlRow> rows = layoutControls(panel, descs);
|
||||||
|
const int id = controlAtPoint(rows, x, y);
|
||||||
|
if (id >= 0) {
|
||||||
|
// Find the row to know its kind + control rect.
|
||||||
|
for (const ControlRow& r : rows) {
|
||||||
|
if (r.id != id) continue;
|
||||||
|
if (r.kind == ControlKind::Toggle) {
|
||||||
|
const int seg = toggleSegmentHitTest(r.control, x, y);
|
||||||
|
if (seg >= 0) {
|
||||||
|
applyControl(id, z.play, 0.0, seg);
|
||||||
|
commitAndReload(); // a toggle is a discrete, final edit
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Grab the slider: set the value at the grab x immediately, then live-drag.
|
||||||
|
drag_ = DragKind::kParamSlider;
|
||||||
|
dragParamId_ = id;
|
||||||
|
dragParamPanel_ = panel;
|
||||||
|
dragStartMap_ = map_;
|
||||||
|
applyControl(id, z.play, valueAtPoint(r.control, x), 0);
|
||||||
|
invalidate(); // live feedback; commit on WM_LBUTTONUP
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -983,9 +1355,18 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
|||||||
// zone over the whole keyboard) and round-trips through the v3 component state; the
|
// 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
|
// 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.
|
// repeated drag edits the same zone rather than stacking duplicates.
|
||||||
|
// Upsert the root override on the picked id; track the zone index so the control panel
|
||||||
|
// stays visible after the zone is materialized on the single-capture face (fix: without
|
||||||
|
// setting selectedZone_ here, selectedZone_==-1 with a non-empty map hides controls).
|
||||||
bool found = false;
|
bool found = false;
|
||||||
for (PerformanceZone& z : map_.zones) {
|
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
||||||
if (z.sampleId == selectedId_) { z.rootOverride = note; found = true; break; }
|
PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
|
||||||
|
if (z.sampleId == selectedId_) {
|
||||||
|
z.rootOverride = note;
|
||||||
|
selectedZone_ = i;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!found) {
|
if (!found) {
|
||||||
PerformanceZone z;
|
PerformanceZone z;
|
||||||
@@ -994,6 +1375,7 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
|||||||
z.highNote = 127;
|
z.highNote = 127;
|
||||||
z.rootOverride = note;
|
z.rootOverride = note;
|
||||||
map_.zones.push_back(z);
|
map_.zones.push_back(z);
|
||||||
|
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
|
||||||
}
|
}
|
||||||
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
|
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
|
||||||
return;
|
return;
|
||||||
@@ -1038,8 +1420,44 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
|||||||
if (m.start > frames - 1) m.start = frames - 1;
|
if (m.start > frames - 1) m.start = frames - 1;
|
||||||
|
|
||||||
// Upsert the override on the picked id (mirror of the root-marker path); commit lands on
|
// Upsert the override on the picked id (mirror of the root-marker path); commit lands on
|
||||||
// release, this is live feedback.
|
// release, this is live feedback. Set selectedZone_ so the control panel stays visible
|
||||||
upsertPickedOverride(m);
|
// after the zone is materialized (fix: without this, selectedZone_==-1 with a non-empty
|
||||||
|
// map hides controls after the first marker drag on the single-capture face).
|
||||||
|
selectedZone_ = upsertPickedOverride(m);
|
||||||
|
invalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drag_ == DragKind::kScrollThumb) {
|
||||||
|
// S12: map the thumb-drag pixel delta to a new (clamped) scroll offset. The visible-card
|
||||||
|
// window recomputes at paint from scrollOffset_. The browser sub-area matches paintBrowser
|
||||||
|
// when a capture is picked (the setup band takes the bottom).
|
||||||
|
const int dyThumb = y - dragStartY_;
|
||||||
|
const bool havePick = !selectedId_.empty();
|
||||||
|
const int setupTop = havePick
|
||||||
|
? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
|
||||||
|
: bands.content.bottom;
|
||||||
|
const BrowserLayout bl = layoutBrowser(bands.content.width(), setupTop - bands.content.top);
|
||||||
|
scrollOffset_ = thumbDragToOffset(bl, static_cast<int>(visible_.size()),
|
||||||
|
dragStartScrollOffset_, dyThumb);
|
||||||
|
invalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drag_ == DragKind::kParamSlider) {
|
||||||
|
// S12/S15/S16: re-lay the panel and map x -> value against the grabbed control's live
|
||||||
|
// track rect (the panel geometry is stable during the drag; re-laying keeps the value
|
||||||
|
// mapping exact even if a mode toggle changed the row set — it did not, mid-drag).
|
||||||
|
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
|
||||||
|
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||||
|
const std::vector<ControlDesc> descs = controlDescs(z.play);
|
||||||
|
const std::vector<ControlRow> rows = layoutControls(dragParamPanel_, descs);
|
||||||
|
for (const ControlRow& r : rows) {
|
||||||
|
if (r.id == dragParamId_) {
|
||||||
|
applyControl(dragParamId_, z.play, valueAtPoint(r.control, x), 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
invalidate();
|
invalidate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1068,11 +1486,79 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
|||||||
|
|
||||||
void ReaSamplerEditor::onMouseUp(int /*x*/, int /*y*/) {
|
void ReaSamplerEditor::onMouseUp(int /*x*/, int /*y*/) {
|
||||||
if (drag_ == DragKind::kNone) return;
|
if (drag_ == DragKind::kNone) return;
|
||||||
|
const DragKind kind = drag_;
|
||||||
drag_ = DragKind::kNone;
|
drag_ = DragKind::kNone;
|
||||||
// One coherent edit lands on release: publish the in-flight map + reload off-thread.
|
// A scrollbar drag is transient UI (no map change) — repaint but do NOT reload. Every other
|
||||||
|
// drag is a coherent map edit: publish the in-flight map + reload off-thread on release.
|
||||||
|
if (kind == DragKind::kScrollThumb) {
|
||||||
|
invalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
commitAndReload();
|
commitAndReload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ReaSamplerEditor::onMouseWheel(int delta) {
|
||||||
|
// S12 browser scroll (only in the browser view). One wheel notch (WHEEL_DELTA==120) scrolls
|
||||||
|
// roughly one card row; the offset is clamped at paint (the layout/panel height is known
|
||||||
|
// there). A positive delta (wheel up) scrolls toward the top (smaller offset).
|
||||||
|
if (view_ != View::kBrowser) return;
|
||||||
|
const int rows = delta / 120;
|
||||||
|
if (rows == 0) return;
|
||||||
|
scrollOffset_ -= rows * kBrowserCardHeight;
|
||||||
|
if (scrollOffset_ < 0) scrollOffset_ = 0; // paint clamps the upper bound to the content
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReaSamplerEditor::onSearchChar(unsigned int ch) {
|
||||||
|
// S12 numeric note-entry (Zones view): a focused low/high/root field accumulates keystrokes
|
||||||
|
// and commits via parseNoteEntry on Enter. Handled before the search box (a field, when
|
||||||
|
// focused, owns the keystrokes).
|
||||||
|
if (view_ == View::kZones && entryField_ >= 0) {
|
||||||
|
if (ch == 13) { // Enter: parse + commit
|
||||||
|
if (auto note = parseNoteEntry(entryText_)) {
|
||||||
|
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||||
|
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||||
|
if (entryField_ == 0) z.lowNote = (std::min)(*note, z.highNote);
|
||||||
|
else if (entryField_ == 1) z.highNote = (std::max)(*note, z.lowNote);
|
||||||
|
else z.rootOverride = *note;
|
||||||
|
commitAndReload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entryField_ = -1;
|
||||||
|
entryText_.clear();
|
||||||
|
invalidate();
|
||||||
|
} else if (ch == 27) { // Escape cancels
|
||||||
|
entryField_ = -1;
|
||||||
|
entryText_.clear();
|
||||||
|
invalidate();
|
||||||
|
} else if (ch == 8) { // backspace
|
||||||
|
if (!entryText_.empty()) entryText_.pop_back();
|
||||||
|
invalidate();
|
||||||
|
} else if (ch >= 32 && ch < 127) {
|
||||||
|
entryText_.push_back(static_cast<char>(ch));
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// S12 type-to-filter search. Only when the search box has focus (a click focuses it). Backspace
|
||||||
|
// deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then search).
|
||||||
|
if (view_ != View::kBrowser || !searchFocused_) return;
|
||||||
|
if (ch == 8) { // backspace
|
||||||
|
if (!searchQuery_.empty()) searchQuery_.pop_back();
|
||||||
|
} else if (ch == 27) { // escape clears + defocuses
|
||||||
|
searchQuery_.clear();
|
||||||
|
searchFocused_ = false;
|
||||||
|
} else if (ch >= 32 && ch < 127) {
|
||||||
|
searchQuery_.push_back(static_cast<char>(ch));
|
||||||
|
} else {
|
||||||
|
return; // ignore other control chars
|
||||||
|
}
|
||||||
|
scrollOffset_ = 0; // a new filter resets the scroll to the top of the narrowed list
|
||||||
|
rebuildVisible();
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||||
LPARAM lParam) {
|
LPARAM lParam) {
|
||||||
auto* self =
|
auto* self =
|
||||||
@@ -1088,12 +1574,24 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
|||||||
case WM_LBUTTONDOWN:
|
case WM_LBUTTONDOWN:
|
||||||
if (self) {
|
if (self) {
|
||||||
SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child
|
SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child
|
||||||
|
SetFocus(hwnd); // take keyboard focus so WM_CHAR reaches the search box (S12)
|
||||||
self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
case WM_MOUSEMOVE:
|
case WM_MOUSEMOVE:
|
||||||
if (self) self->onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
if (self) self->onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
||||||
return 0;
|
return 0;
|
||||||
|
case WM_MOUSEWHEEL:
|
||||||
|
// S12 browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up.
|
||||||
|
if (self) self->onMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam));
|
||||||
|
return 0;
|
||||||
|
case WM_CHAR:
|
||||||
|
// S12 type-to-filter search keystrokes (only acted on when the search box is focused).
|
||||||
|
if (self) self->onSearchChar(static_cast<unsigned int>(wParam));
|
||||||
|
return 0;
|
||||||
|
case WM_GETDLGCODE:
|
||||||
|
// Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (S12 search).
|
||||||
|
return DLGC_WANTCHARS | DLGC_WANTARROWS;
|
||||||
case WM_LBUTTONUP:
|
case WM_LBUTTONUP:
|
||||||
if (self) {
|
if (self) {
|
||||||
self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
||||||
@@ -1106,7 +1604,10 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
|||||||
// the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing.
|
// the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing.
|
||||||
// Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler.
|
// Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler.
|
||||||
if (self && self->drag_ != DragKind::kNone) {
|
if (self && self->drag_ != DragKind::kNone) {
|
||||||
self->map_ = self->dragStartMap_;
|
// A scrollbar drag is transient (no map mutation + dragStartMap_ was not
|
||||||
|
// snapshotted for it) — reset the drag state only, never touch map_. Every
|
||||||
|
// map-editing drag rolls its live mutation back to the pre-grab snapshot.
|
||||||
|
if (self->drag_ != DragKind::kScrollThumb) self->map_ = self->dragStartMap_;
|
||||||
self->drag_ = DragKind::kNone;
|
self->drag_ = DragKind::kNone;
|
||||||
self->invalidate();
|
self->invalidate();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
#include "public.sdk/source/common/pluginview.h"
|
#include "public.sdk/source/common/pluginview.h"
|
||||||
|
|
||||||
#include "editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules)
|
#include "editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules)
|
||||||
|
#include "param_slider.h" // ControlRow (the S12/S15/S16 control-surface geometry)
|
||||||
#include "peaks.h" // Envelope (the cached peak thumbnail)
|
#include "peaks.h" // Envelope (the cached peak thumbnail)
|
||||||
#include "sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot)
|
#include "sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot)
|
||||||
|
|
||||||
@@ -70,7 +71,29 @@ private:
|
|||||||
// flight. The zone-edit grabs mirror keyboard_strip::ZoneGrab; kRootMarker is the
|
// flight. The zone-edit grabs mirror keyboard_strip::ZoneGrab; kRootMarker is the
|
||||||
// single-capture root drag on the setup strip; kWaveMarker is a draggable start/loop
|
// single-capture root drag on the setup strip; kWaveMarker is a draggable start/loop
|
||||||
// marker on the S11 waveform surface (which marker is in waveMarker_).
|
// marker on the S11 waveform surface (which marker is in waveMarker_).
|
||||||
enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker };
|
enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker,
|
||||||
|
kScrollThumb, kParamSlider };
|
||||||
|
|
||||||
|
// The parameter controls on the setup surface (S12 AHDSR + the S15/S16 control surfaces).
|
||||||
|
// The int value is the ControlDesc id the pure param_slider hit-test returns; the shell
|
||||||
|
// maps it to the picked zone's play params. Order here is the panel's top-down stack order.
|
||||||
|
enum class ParamControl {
|
||||||
|
kPlayMode = 0, // Gate | Trigger toggle (S15)
|
||||||
|
kPitchEngine, // Varispeed | Preserve toggle (S16)
|
||||||
|
kAttack, // AHDSR attack (Gate) / —
|
||||||
|
kHold, // AHDSR hold (Gate, S15)
|
||||||
|
kDecay, // AHDSR decay (Gate)
|
||||||
|
kSustain, // AHDSR sustain (Gate)
|
||||||
|
kRelease, // AHDSR release (Gate)
|
||||||
|
kTrigLength, // Trigger %-length (Trigger, S15)
|
||||||
|
kTrigFadeIn, // Trigger fade-in (Trigger, S15)
|
||||||
|
kTrigFadeOut, // Trigger fade-out (Trigger, S15)
|
||||||
|
kPitchEnvEnable, // AD pitch envelope on|off (S16)
|
||||||
|
kPitchEnvAttack, // AD pitch attack (S16)
|
||||||
|
kPitchEnvDecay, // AD pitch decay (S16)
|
||||||
|
kPitchEnvDepth, // AD pitch depth in +/- semitones (S16)
|
||||||
|
kCount
|
||||||
|
};
|
||||||
|
|
||||||
// The waveform markers on the single-capture setup surface (S11). Order is the draw + hit
|
// The waveform markers on the single-capture setup surface (S11). Order is the draw + hit
|
||||||
// order (start first). Named generically per the spec so S15 can repurpose the surface with
|
// order (start first). Named generically per the spec so S15 can repurpose the surface with
|
||||||
@@ -83,10 +106,13 @@ private:
|
|||||||
void paintSetup(LICE_IBitmap* bmp, const Rect& area);
|
void paintSetup(LICE_IBitmap* bmp, const Rect& area);
|
||||||
void paintZones(LICE_IBitmap* bmp, int w, int h);
|
void paintZones(LICE_IBitmap* bmp, int w, int h);
|
||||||
void paintEmptyState(LICE_IBitmap* bmp, const Rect& area);
|
void paintEmptyState(LICE_IBitmap* bmp, const Rect& area);
|
||||||
|
void paintControls(LICE_IBitmap* bmp, const Rect& panel); // S12/S15/S16 param surface
|
||||||
|
|
||||||
void onMouseDown(int x, int y);
|
void onMouseDown(int x, int y);
|
||||||
void onMouseMove(int x, int y);
|
void onMouseMove(int x, int y);
|
||||||
void onMouseUp(int x, int y);
|
void onMouseUp(int x, int y);
|
||||||
|
void onMouseWheel(int delta); // S12 browser scroll (wheel)
|
||||||
|
void onSearchChar(unsigned int ch); // S12 type-to-filter search keystroke
|
||||||
|
|
||||||
// The S9/S8 change-detection tick (WM_TIMER on the child window — the UI thread, NEVER the
|
// The S9/S8 change-detection tick (WM_TIMER on the child window — the UI thread, NEVER the
|
||||||
// audio thread). Polls the processor's bank-sync (generation change -> hands-free reload;
|
// audio thread). Polls the processor's bank-sync (generation change -> hands-free reload;
|
||||||
@@ -146,8 +172,30 @@ private:
|
|||||||
|
|
||||||
// Write `m` as a loop/start override upsert into map_ for selectedId_ (find-or-append).
|
// Write `m` as a loop/start override upsert into map_ for selectedId_ (find-or-append).
|
||||||
// Does NOT call commitAndReload — callers decide whether this is a live-drag update or a
|
// Does NOT call commitAndReload — callers decide whether this is a live-drag update or a
|
||||||
// final commit. selectedId_ must be non-empty before calling.
|
// final commit. selectedId_ must be non-empty before calling. Returns the zone index
|
||||||
void upsertPickedOverride(const SetupMarkers& m);
|
// (0-based) that was updated or appended, so callers can set selectedZone_.
|
||||||
|
int upsertPickedOverride(const SetupMarkers& m);
|
||||||
|
|
||||||
|
// --- S12/S15/S16 parameter surface (Zones panel, keyed to selectedZone_) ------
|
||||||
|
//
|
||||||
|
// The control panel edits the SELECTED zone's ZonePlaySeconds (S15 play mode + AHDSR; S16
|
||||||
|
// pitch engine + AD pitch envelope). Wall-clock times are SECONDS (rate-free); the keymap
|
||||||
|
// build resolves them to frames at the live rate. Instrument-owned (D-B), never a bank fact.
|
||||||
|
|
||||||
|
// The control descriptors the panel shows for `play`'s CURRENT play mode: the two toggles +
|
||||||
|
// the mode-relevant sliders (AHDSR for Gate, %-length/fades for Trigger) + the pitch-envelope
|
||||||
|
// controls. The pure param_slider lays these out; this only picks the set. Static (a free
|
||||||
|
// choice of set from the mode) — kept a member for the ParamControl enum access.
|
||||||
|
std::vector<ControlDesc> controlDescs(const ZonePlaySeconds& play) const;
|
||||||
|
|
||||||
|
// The normalized [0,1] display value for control `id` given `play` (the shell's domain
|
||||||
|
// mapping: seconds->0..1 over a fixed seconds ceiling, sustain 0..1 as-is, %-length/fade
|
||||||
|
// frames->0..1, semitone depth centered at 0.5).
|
||||||
|
double controlValue(int id, const ZonePlaySeconds& play) const;
|
||||||
|
|
||||||
|
// Apply a committed control interaction to `play`: a slider's normalized `value` (mapped back
|
||||||
|
// into the control's stored domain) or a toggle's `segment` (0/1). Mutates `play` in place.
|
||||||
|
void applyControl(int id, ZonePlaySeconds& play, double value, int segment) const;
|
||||||
|
|
||||||
ReaSamplerProcessor* processor_ = nullptr;
|
ReaSamplerProcessor* processor_ = nullptr;
|
||||||
|
|
||||||
@@ -164,9 +212,22 @@ private:
|
|||||||
std::string activeFilterBankId_; // "" = All; else a bank id from banks_
|
std::string activeFilterBankId_; // "" = All; else a bank id from banks_
|
||||||
int selectedZone_ = -1; // highlighted zone in the Zones panel; -1 = none
|
int selectedZone_ = -1; // highlighted zone in the Zones panel; -1 = none
|
||||||
|
|
||||||
|
// --- S12 browser scroll + search (transient UI state, never persisted) --------
|
||||||
|
int scrollOffset_ = 0; // vertical px offset into the card grid (clamped)
|
||||||
|
std::string searchQuery_; // type-to-filter narrow; "" = no search
|
||||||
|
bool searchFocused_ = false; // whether the search box has keyboard focus
|
||||||
|
|
||||||
|
// --- S12 numeric note entry (LICE text-entry idiom, transient) ----------------
|
||||||
|
// When >= 0, a low/high/root field is being typed; entryText_ accumulates the keystrokes
|
||||||
|
// and commits (parseNoteEntry) on Enter. -1 = no field editing. The field id is a
|
||||||
|
// ParamControl-independent small enum encoded inline (see the .cpp: 0=low,1=high,2=root).
|
||||||
|
int entryField_ = -1;
|
||||||
|
std::string entryText_;
|
||||||
|
|
||||||
// --- Drag-state machine ------------------------------------------------------
|
// --- Drag-state machine ------------------------------------------------------
|
||||||
DragKind drag_ = DragKind::kNone;
|
DragKind drag_ = DragKind::kNone;
|
||||||
int dragStartX_ = 0; // grab x (px), for the pixel-delta resolver
|
int dragStartX_ = 0; // grab x (px), for the pixel-delta resolver
|
||||||
|
int dragStartY_ = 0; // grab y (px), for the vertical scrollbar-thumb drag
|
||||||
int dragStartLow_ = 0; // the grabbed field's note at grab time
|
int dragStartLow_ = 0; // the grabbed field's note at grab time
|
||||||
int dragStartHigh_ = 0;
|
int dragStartHigh_ = 0;
|
||||||
int dragStartRoot_ = 60;
|
int dragStartRoot_ = 60;
|
||||||
@@ -179,6 +240,13 @@ private:
|
|||||||
SetupMarkers dragStartMarkers_;
|
SetupMarkers dragStartMarkers_;
|
||||||
std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the drag
|
std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the drag
|
||||||
|
|
||||||
|
// S12 scrollbar-thumb drag: the offset held at grab time (the pixel-delta resolver shifts
|
||||||
|
// from it). S12/S15/S16 param-slider drag: which control id + the panel it lives in (the
|
||||||
|
// shell re-lays the panel each move to map x->value against the live control rect).
|
||||||
|
int dragStartScrollOffset_ = 0;
|
||||||
|
int dragParamId_ = -1;
|
||||||
|
Rect dragParamPanel_{};
|
||||||
|
|
||||||
// --- Peak-thumbnail cache (mirror of bank_panel; id -> envelope at a bin width) ------
|
// --- 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
|
// 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.
|
// a bank edit (a re-captured or deleted sample) does not show a stale thumbnail.
|
||||||
|
|||||||
@@ -37,10 +37,6 @@ namespace {
|
|||||||
// notes neither click on nor cut off abruptly; sustain at unity (velocity does the
|
// notes neither click on nor cut off abruptly; sustain at unity (velocity does the
|
||||||
// dynamics), a short release for a natural tail. Times are in seconds, converted to
|
// dynamics), a short release for a natural tail. Times are in seconds, converted to
|
||||||
// frames against the live sample rate at build time.
|
// frames against the live sample rate at build time.
|
||||||
constexpr double kAttackSeconds = 0.003;
|
|
||||||
constexpr double kDecaySeconds = 0.0;
|
|
||||||
constexpr double kSustainLevel = 1.0;
|
|
||||||
constexpr double kReleaseSeconds = 0.060;
|
|
||||||
constexpr std::size_t kMaxVoices = 16;
|
constexpr std::size_t kMaxVoices = 16;
|
||||||
|
|
||||||
// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is
|
// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is
|
||||||
@@ -50,16 +46,6 @@ constexpr std::size_t kMaxVoices = 16;
|
|||||||
// cost — see the handoff CPU note. 8 is a conservative half of kMaxVoices pending DAW profiling.
|
// cost — see the handoff CPU note. 8 is a conservative half of kMaxVoices pending DAW profiling.
|
||||||
constexpr std::size_t kPreserveVoiceCap = 8;
|
constexpr std::size_t kPreserveVoiceCap = 8;
|
||||||
|
|
||||||
AdsrParams tier0Adsr(double sampleRate) {
|
|
||||||
const double sr = sampleRate > 0.0 ? sampleRate : 44100.0;
|
|
||||||
AdsrParams p;
|
|
||||||
p.attackFrames = static_cast<std::int64_t>(kAttackSeconds * sr);
|
|
||||||
p.decayFrames = static_cast<std::int64_t>(kDecaySeconds * sr);
|
|
||||||
p.sustainLevel = kSustainLevel;
|
|
||||||
p.releaseFrames = static_cast<std::int64_t>(kReleaseSeconds * sr);
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on
|
// Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on
|
||||||
// any failure — the caller treats an unreadable WAV as "nothing to play".
|
// any failure — the caller treats an unreadable WAV as "nothing to play".
|
||||||
std::vector<std::uint8_t> readFileBytes(const std::string& path) {
|
std::vector<std::uint8_t> readFileBytes(const std::string& path) {
|
||||||
@@ -190,7 +176,11 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
|||||||
// blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so
|
// blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so
|
||||||
// the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10
|
// the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10
|
||||||
// silent empty state (no first-sample fallback in reloadFromBank).
|
// silent empty state (no first-sample fallback in reloadFromBank).
|
||||||
const ComponentState cs = deserializeComponentState(bytes);
|
// Pass sampleRate_ as the project rate for legacy v3 blob conversion (frames -> seconds at
|
||||||
|
// the v3 read boundary). sampleRate_ is set by setupProcessing; REAPER calls setupProcessing
|
||||||
|
// before setState on project load, so sampleRate_ is the real host rate here. A v3 blob on a
|
||||||
|
// pre-setup call would assert inside readZonesPayload (a programming error, not a field case).
|
||||||
|
const ComponentState cs = deserializeComponentState(bytes, sampleRate_);
|
||||||
setSelectedSampleId(cs.selectionId);
|
setSelectedSampleId(cs.selectionId);
|
||||||
setPerformanceMap(cs.map);
|
setPerformanceMap(cs.map);
|
||||||
// S8: restore the last-consumed assignment generation so a re-open does not re-apply a
|
// S8: restore the last-consumed assignment generation so a re-open does not re-apply a
|
||||||
@@ -384,12 +374,13 @@ std::string ReaSamplerProcessor::reloadFromBank() {
|
|||||||
if (haveKeymap) {
|
if (haveKeymap) {
|
||||||
// Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs).
|
// Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs).
|
||||||
// Every voice's shifter is pre-sized to this off-thread here, so process()-time
|
// Every voice's shifter is pre-sized to this off-thread here, so process()-time
|
||||||
// note-on never allocates. Floored at 2 so a valid window is always a real ring.
|
// note-on never allocates. Floored at 2 so a valid window is always a real ring
|
||||||
|
// (which also covers a pathological host rate <= 0 — no rate literal needed).
|
||||||
std::int64_t preserveWindow = static_cast<std::int64_t>(
|
std::int64_t preserveWindow = static_cast<std::int64_t>(
|
||||||
kPreserveWindowMs * (sampleRate_ > 0.0 ? sampleRate_ : 44100.0) / 1000.0 + 0.5);
|
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
||||||
if (preserveWindow < 2) preserveWindow = 2;
|
if (preserveWindow < 2) preserveWindow = 2;
|
||||||
built = std::make_unique<LoadedInstrument>(
|
built = std::make_unique<LoadedInstrument>(
|
||||||
std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen, kPreserveVoiceCap,
|
std::move(km), kMaxVoices, gen, kPreserveVoiceCap,
|
||||||
preserveWindow);
|
preserveWindow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,11 +50,11 @@ struct LoadedInstrument {
|
|||||||
VoiceEngine engine;
|
VoiceEngine engine;
|
||||||
std::uint64_t installedAt = 0; // reload generation at which this was installed
|
std::uint64_t installedAt = 0; // reload generation at which this was installed
|
||||||
|
|
||||||
LoadedInstrument(Keymap km, std::size_t maxVoices, const AdsrParams& adsr,
|
LoadedInstrument(Keymap km, std::size_t maxVoices,
|
||||||
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
|
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
|
||||||
std::int64_t preserveWindowFrames = 0)
|
std::int64_t preserveWindowFrames = 0)
|
||||||
: keymap(std::move(km)),
|
: keymap(std::move(km)),
|
||||||
engine(maxVoices, keymap, adsr, preserveVoiceCap, preserveWindowFrames),
|
engine(maxVoices, keymap, preserveVoiceCap, preserveWindowFrames),
|
||||||
installedAt(gen) {}
|
installedAt(gen) {}
|
||||||
|
|
||||||
LoadedInstrument(const LoadedInstrument&) = delete;
|
LoadedInstrument(const LoadedInstrument&) = delete;
|
||||||
@@ -251,8 +251,9 @@ private:
|
|||||||
std::int64_t lastSeenBankGeneration_ = -1;
|
std::int64_t lastSeenBankGeneration_ = -1;
|
||||||
|
|
||||||
// Latched from setupProcessing so setActive/reload can size against it. Read
|
// Latched from setupProcessing so setActive/reload can size against it. Read
|
||||||
// off-thread only.
|
// off-thread only. 0.0 is explicitly invalid — setupProcessing sets the real host rate
|
||||||
double sampleRate_ = 44100.0;
|
// before any audio, and reloadFromBank guards on it before use.
|
||||||
|
double sampleRate_ = 0.0;
|
||||||
Steinberg::int32 maxBlockSize_ = 4096;
|
Steinberg::int32 maxBlockSize_ = 4096;
|
||||||
|
|
||||||
// --- S6 embedded TCP/MCP UI ---------------------------------------------
|
// --- S6 embedded TCP/MCP UI ---------------------------------------------
|
||||||
|
|||||||
+125
-43
@@ -4,6 +4,7 @@
|
|||||||
#include "sample_map.h"
|
#include "sample_map.h"
|
||||||
|
|
||||||
#include <algorithm> // std::min
|
#include <algorithm> // std::min
|
||||||
|
#include <cassert> // assert
|
||||||
#include <cstring> // std::memcpy
|
#include <cstring> // std::memcpy
|
||||||
#include <utility> // std::move
|
#include <utility> // std::move
|
||||||
|
|
||||||
@@ -118,8 +119,10 @@ std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interlea
|
|||||||
|
|
||||||
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||||
int sourceChannels, ChannelMode mode, int sampleRate) {
|
int sourceChannels, ChannelMode mode, int sampleRate) {
|
||||||
|
assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)");
|
||||||
DecodedZonePcm out;
|
DecodedZonePcm out;
|
||||||
out.sampleRate = sampleRate > 0 ? sampleRate : 44100;
|
if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
|
||||||
|
out.sampleRate = sampleRate;
|
||||||
if (mode == ChannelMode::Mono) {
|
if (mode == ChannelMode::Mono) {
|
||||||
// MONO mode: the existing downmix policy (average all source channels), one channel out.
|
// MONO mode: the existing downmix policy (average all source channels), one channel out.
|
||||||
out.monoFrames = downmixToMono(interleaved, sourceChannels);
|
out.monoFrames = downmixToMono(interleaved, sourceChannels);
|
||||||
@@ -133,9 +136,37 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
|
||||||
|
// seconds -> frames at the LIVE rate (round-to-nearest). Wall-clock quantities (AHDSR A/H/D/R,
|
||||||
|
// pitch env A/D) resolve here; source-timeline quantities (trigger %-length + fades) carry
|
||||||
|
// through untouched — they are already source frames / fractions. Non-time fields pass as-is.
|
||||||
|
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
|
||||||
|
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
|
||||||
|
const auto secToFrames = [sr](double sec) {
|
||||||
|
double f = sec * sr;
|
||||||
|
if (f < 0.0) f = 0.0;
|
||||||
|
return static_cast<std::int64_t>(f + 0.5);
|
||||||
|
};
|
||||||
|
ZonePlayParams out;
|
||||||
|
out.playMode = stored.playMode;
|
||||||
|
out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds);
|
||||||
|
out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds);
|
||||||
|
out.adsr.decayFrames = secToFrames(stored.adsr.decaySeconds);
|
||||||
|
out.adsr.sustainLevel = stored.adsr.sustainLevel; // level, not a time
|
||||||
|
out.adsr.releaseFrames = secToFrames(stored.adsr.releaseSeconds);
|
||||||
|
out.trigger = stored.trigger; // source-frame / fraction, unchanged
|
||||||
|
out.pitchEngine = stored.pitchEngine;
|
||||||
|
out.pitchEnv.enabled = stored.pitchEnv.enabled;
|
||||||
|
out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds);
|
||||||
|
out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds);
|
||||||
|
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
|
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
|
||||||
int rootNote, const SampleLoop& loop,
|
int rootNote, const SampleLoop& loop,
|
||||||
std::vector<AudioSample> framesR, const ZonePlayParams& play) {
|
std::vector<AudioSample> framesR, const ZonePlaySeconds& play) {
|
||||||
|
assert(sampleRate > 0 && "buildTier0Keymap: sampleRate must be > 0 (programming error)");
|
||||||
SampleData data;
|
SampleData data;
|
||||||
data.frames = std::move(frames);
|
data.frames = std::move(frames);
|
||||||
// A second channel only counts when it length-matches channel 0 (else the sample stays
|
// A second channel only counts when it length-matches channel 0 (else the sample stays
|
||||||
@@ -143,10 +174,13 @@ Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
|
|||||||
if (!framesR.empty() && framesR.size() == data.frames.size()) {
|
if (!framesR.empty() && framesR.size() == data.frames.size()) {
|
||||||
data.framesR = std::move(framesR);
|
data.framesR = std::move(framesR);
|
||||||
}
|
}
|
||||||
data.sampleRate = sampleRate > 0 ? sampleRate : 44100;
|
if (sampleRate <= 0) return Keymap{}; // safe early-return; assert fires first
|
||||||
|
data.sampleRate = sampleRate;
|
||||||
data.rootNote = rootNote;
|
data.rootNote = rootNote;
|
||||||
data.loop = loop;
|
data.loop = loop;
|
||||||
data.play = play; // S15/S16 single-capture play params (product defaults unless overridden)
|
// Resolve the stored wall-clock SECONDS to the engine's frame domain at the WAV's actual rate.
|
||||||
|
data.play = resolvePlay(play, data.sampleRate);
|
||||||
|
|
||||||
return Keymap::singleSampleChromatic(std::move(data));
|
return Keymap::singleSampleChromatic(std::move(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,8 +221,8 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
|||||||
// never mutated — this only shapes what the core plays for THIS instance (D-B).
|
// never mutated — this only shapes what the core plays for THIS instance (D-B).
|
||||||
rz.loop = z.loopOverride ? *z.loopOverride : loopFromSample(*found);
|
rz.loop = z.loopOverride ? *z.loopOverride : loopFromSample(*found);
|
||||||
rz.startFrame = z.startPoint ? *z.startPoint : 0;
|
rz.startFrame = z.startPoint ? *z.startPoint : 0;
|
||||||
// S15/S16 per-zone play params carry through unchanged (they are instrument state, not
|
// S15/S16 per-zone play params (SECONDS) carry through unchanged (they are instrument
|
||||||
// resolved against the bank) so the keymap build can stamp them onto the SampleData.
|
// state, not resolved against the bank); buildZonedKeymap resolves them to frames.
|
||||||
rz.play = z.play;
|
rz.play = z.play;
|
||||||
out.zones.push_back(std::move(rz));
|
out.zones.push_back(std::move(rz));
|
||||||
}
|
}
|
||||||
@@ -210,11 +244,16 @@ Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
|||||||
decoded[i].framesR.size() == data.frames.size()) {
|
decoded[i].framesR.size() == data.frames.size()) {
|
||||||
data.framesR = decoded[i].framesR;
|
data.framesR = decoded[i].framesR;
|
||||||
}
|
}
|
||||||
data.sampleRate = decoded[i].sampleRate > 0 ? decoded[i].sampleRate : 44100;
|
assert(decoded[i].sampleRate > 0 &&
|
||||||
|
"buildZonedKeymap: DecodedZonePcm::sampleRate must be > 0 (programming error)");
|
||||||
|
if (decoded[i].sampleRate <= 0) continue; // safe skip; assert fires first
|
||||||
|
data.sampleRate = decoded[i].sampleRate;
|
||||||
data.rootNote = zones[i].rootNote;
|
data.rootNote = zones[i].rootNote;
|
||||||
data.loop = zones[i].loop;
|
data.loop = zones[i].loop;
|
||||||
data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0)
|
data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0)
|
||||||
data.play = zones[i].play; // S15/S16 per-zone play mode + engine + envelopes
|
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
|
||||||
|
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
|
||||||
|
data.play = resolvePlay(zones[i].play, data.sampleRate);
|
||||||
const std::size_t sampleIndex = km.samples.size();
|
const std::size_t sampleIndex = km.samples.size();
|
||||||
km.samples.push_back(std::move(data));
|
km.samples.push_back(std::move(data));
|
||||||
KeyZone zone;
|
KeyZone zone;
|
||||||
@@ -314,11 +353,13 @@ struct ByteReader {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Append the zones payload — the shared body of the v2 performance blob and the v3 component
|
// Append the zones payload — the shared body of the performance blob and the component blob,
|
||||||
// blob, so both write zones identically. Always emits PAYLOAD v2 (the S11 self-describing
|
// so both write zones identically. Always emits the CURRENT PAYLOAD version (kZonesPayloadVersion
|
||||||
// marker + version + EXTENDED records): the marker precedes the zone count so any reader can
|
// == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail
|
||||||
// detect the record shape independently of the envelope version (see sample_map.h). The S11
|
// AND the full play-params tail with wall-clock times stored as SECONDS): the marker precedes
|
||||||
// loop/start overrides therefore round-trip through EITHER envelope with no envelope bump.
|
// the zone count so any reader can detect the record shape independently of the envelope version
|
||||||
|
// (see sample_map.h). The S11 loop/start overrides and the play params therefore round-trip
|
||||||
|
// through EITHER envelope with no envelope bump.
|
||||||
void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) {
|
void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) {
|
||||||
putU32le(out, kZonesFormatMarker);
|
putU32le(out, kZonesFormatMarker);
|
||||||
putU32le(out, kZonesPayloadVersion);
|
putU32le(out, kZonesPayloadVersion);
|
||||||
@@ -343,19 +384,25 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
|
|||||||
out.push_back(z.startPoint ? 1 : 0);
|
out.push_back(z.startPoint ? 1 : 0);
|
||||||
if (z.startPoint) putU64le(out, asU64(*z.startPoint));
|
if (z.startPoint) putU64le(out, asU64(*z.startPoint));
|
||||||
|
|
||||||
// S15/S16 extension (PAYLOAD v3): the per-zone play params, always present (every zone
|
// S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine).
|
||||||
// has a play mode + engine — no flag gate). Order matches the header's v3 record spec.
|
// Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames /
|
||||||
const ZonePlayParams& pp = z.play;
|
// fraction. Order matches the header's v5 record spec.
|
||||||
|
const ZonePlaySeconds& pp = z.play;
|
||||||
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
|
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
|
||||||
putU64le(out, asU64(pp.adsr.holdFrames));
|
putU64le(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
|
||||||
putU64le(out, doubleToBits(pp.trigger.lengthFraction));
|
putU64le(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
|
||||||
putU64le(out, asU64(pp.trigger.fadeInFrames));
|
putU64le(out, asU64(pp.trigger.fadeInFrames)); // source frames
|
||||||
putU64le(out, asU64(pp.trigger.fadeOutFrames));
|
putU64le(out, asU64(pp.trigger.fadeOutFrames)); // source frames
|
||||||
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
|
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
|
||||||
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
|
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
|
||||||
putU64le(out, asU64(pp.pitchEnv.attackFrames));
|
putU64le(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds
|
||||||
putU64le(out, asU64(pp.pitchEnv.decayFrames));
|
putU64le(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
|
||||||
putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones));
|
putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
|
||||||
|
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
|
||||||
|
putU64le(out, doubleToBits(pp.adsr.attackSeconds));
|
||||||
|
putU64le(out, doubleToBits(pp.adsr.decaySeconds));
|
||||||
|
putU64le(out, doubleToBits(pp.adsr.sustainLevel));
|
||||||
|
putU64le(out, doubleToBits(pp.adsr.releaseSeconds));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,19 +411,23 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
|
|||||||
// loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail —
|
// loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail —
|
||||||
// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read
|
// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read
|
||||||
// keeps the zones that parsed cleanly and drops the rest.
|
// keeps the zones that parsed cleanly and drops the rest.
|
||||||
void readZonesPayload(ByteReader& r, PerformanceMap& map) {
|
// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock frame
|
||||||
bool extended = false; // v2+: the S11 loop/start tail is present
|
// counts (holdFrames, pitchEnv A/D) to the seconds domain at the read boundary: seconds = frames /
|
||||||
bool hasPlay = false; // v3+: the S15/S16 play-params tail is present
|
// projectRate. Must be > 0 (callers guard). v5 and later blobs carry seconds directly; no rate needed.
|
||||||
|
void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
|
||||||
|
bool extended = false; // v2+: the S11 loop/start tail is present
|
||||||
|
std::uint32_t pv = 0; // payload version (0 = v1, no marker)
|
||||||
if (r.peekU32() == kZonesFormatMarker) {
|
if (r.peekU32() == kZonesFormatMarker) {
|
||||||
r.u32(); // consume the marker
|
r.u32(); // consume the marker
|
||||||
const std::uint32_t pv = r.u32(); // payload version
|
pv = r.u32(); // payload version
|
||||||
extended = (pv >= 2); // v2+ carries the loop/start tail
|
extended = (pv >= 2); // v2+ carries the loop/start tail
|
||||||
hasPlay = (pv >= 3); // v3+ carries the S15/S16 play-params tail
|
|
||||||
}
|
}
|
||||||
|
const bool legacyV3Play = (pv == 3); // legacy S15/S16 play tail, wall-clock in 44.1k frames
|
||||||
|
const bool secondsPlay = (pv >= 5); // current: full play params, wall-clock in seconds
|
||||||
const std::uint32_t count = r.u32();
|
const std::uint32_t count = r.u32();
|
||||||
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
||||||
// z.play defaults to the PRODUCT defaults (Gate + Preserve). A v1/v2 payload (no play
|
// z.play defaults to the PRODUCT defaults (Gate + Preserve + tier-0 AHDSR seconds). A
|
||||||
// tail) therefore lifts every zone to those defaults — the deliberate S16-F1 change.
|
// v1/v2 payload (no play tail) therefore lifts every zone to those defaults (S16-F1).
|
||||||
PerformanceZone z;
|
PerformanceZone z;
|
||||||
const std::uint32_t idLen = r.u32();
|
const std::uint32_t idLen = r.u32();
|
||||||
z.sampleId = r.str(idLen);
|
z.sampleId = r.str(idLen);
|
||||||
@@ -396,19 +447,43 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) {
|
|||||||
const std::uint8_t hasStart = r.u8();
|
const std::uint8_t hasStart = r.u8();
|
||||||
if (hasStart) z.startPoint = r.i64();
|
if (hasStart) z.startPoint = r.i64();
|
||||||
}
|
}
|
||||||
if (hasPlay) {
|
if (legacyV3Play) {
|
||||||
// S15/S16 play params, always present in a v3 record (read in the emit order).
|
// LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D)
|
||||||
|
// were written as frames -> divide by the project sample rate (threaded in as `projectRate`)
|
||||||
|
// to reach the seconds domain. Trigger %-length + fades are source-timeline, read as-is.
|
||||||
|
// A/D/S/R are ABSENT in v3 -> leave the seconds defaults on z.play.adsr.
|
||||||
|
assert(projectRate > 0.0 && "readZonesPayload: projectRate must be > 0 for v3 lift");
|
||||||
|
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // 1.0 avoids div-by-zero; assert fires first
|
||||||
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||||
z.play.adsr.holdFrames = r.i64();
|
z.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||||
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||||
z.play.trigger.fadeInFrames = r.i64();
|
z.play.trigger.fadeInFrames = r.i64();
|
||||||
z.play.trigger.fadeOutFrames = r.i64();
|
z.play.trigger.fadeOutFrames = r.i64();
|
||||||
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||||
z.play.pitchEnv.enabled = (r.u8() != 0);
|
z.play.pitchEnv.enabled = (r.u8() != 0);
|
||||||
z.play.pitchEnv.attackFrames = r.i64();
|
z.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||||
z.play.pitchEnv.decayFrames = r.i64();
|
z.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
|
||||||
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||||
|
} else if (secondsPlay) {
|
||||||
|
// Current v5 play tail: wall-clock times in SECONDS (doubles); trigger fades in source
|
||||||
|
// frames; read in the emit order.
|
||||||
|
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||||
|
z.play.adsr.holdSeconds = bitsToDouble(r.u64());
|
||||||
|
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||||
|
z.play.trigger.fadeInFrames = r.i64();
|
||||||
|
z.play.trigger.fadeOutFrames = r.i64();
|
||||||
|
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||||
|
z.play.pitchEnv.enabled = (r.u8() != 0);
|
||||||
|
z.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
|
||||||
|
z.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
|
||||||
|
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||||
|
z.play.adsr.attackSeconds = bitsToDouble(r.u64());
|
||||||
|
z.play.adsr.decaySeconds = bitsToDouble(r.u64());
|
||||||
|
z.play.adsr.sustainLevel = bitsToDouble(r.u64());
|
||||||
|
z.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
||||||
}
|
}
|
||||||
|
// Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the
|
||||||
|
// seconds product defaults on z.play — a v4 blob cannot exist outside this branch.
|
||||||
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
|
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
|
||||||
map.zones.push_back(std::move(z));
|
map.zones.push_back(std::move(z));
|
||||||
}
|
}
|
||||||
@@ -423,7 +498,11 @@ std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes) {
|
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||||
|
double projectRate) {
|
||||||
|
// projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present.
|
||||||
|
// For v5 and later blobs it is unused. The assert inside readZonesPayload fires if a v3
|
||||||
|
// blob is encountered with an invalid rate — the calller guarantees a real rate before use.
|
||||||
PerformanceMap map;
|
PerformanceMap map;
|
||||||
ByteReader r(bytes);
|
ByteReader r(bytes);
|
||||||
const std::uint32_t version = r.u32();
|
const std::uint32_t version = r.u32();
|
||||||
@@ -444,7 +523,7 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes) {
|
|||||||
}
|
}
|
||||||
if (version != kPerformanceStateVersion) return map; // unknown -> empty
|
if (version != kPerformanceStateVersion) return map; // unknown -> empty
|
||||||
|
|
||||||
readZonesPayload(r, map);
|
readZonesPayload(r, map, projectRate);
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +546,10 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes) {
|
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||||
|
double projectRate) {
|
||||||
|
// projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present.
|
||||||
|
// For v5 and later blobs it is unused. See readZonesPayload for the guard.
|
||||||
ComponentState out;
|
ComponentState out;
|
||||||
ByteReader r(bytes);
|
ByteReader r(bytes);
|
||||||
const std::uint32_t version = r.u32();
|
const std::uint32_t version = r.u32();
|
||||||
@@ -490,8 +572,8 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes)
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
if (version == kPerformanceStateVersion) {
|
if (version == kPerformanceStateVersion) {
|
||||||
readZonesPayload(r, out.map); // v2 body starts right after the version tag
|
readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag
|
||||||
return out; // channelMode stays Mono (pre-S7)
|
return out; // channelMode stays Mono (pre-S7)
|
||||||
}
|
}
|
||||||
// BACK-COMPAT: a v3 blob (pre-S7 {selection, zones}, no channel mode) restores as MONO —
|
// BACK-COMPAT: a v3 blob (pre-S7 {selection, zones}, no channel mode) restores as MONO —
|
||||||
// the id length + id + zones body starts right after the version tag (no mode byte).
|
// the id length + id + zones body starts right after the version tag (no mode byte).
|
||||||
@@ -499,7 +581,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes)
|
|||||||
const std::uint32_t idLen = r.u32();
|
const std::uint32_t idLen = r.u32();
|
||||||
out.selectionId = r.str(idLen);
|
out.selectionId = r.str(idLen);
|
||||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||||
readZonesPayload(r, out.map);
|
readZonesPayload(r, out.map, projectRate);
|
||||||
return out; // channelMode stays Mono, marker stays 0 (pre-S7/S8/S9)
|
return out; // channelMode stays Mono, marker stays 0 (pre-S7/S8/S9)
|
||||||
}
|
}
|
||||||
// BACK-COMPAT: a v4 blob (pre-S8/S9 reader {mode, selection, zones}, no consumed marker):
|
// BACK-COMPAT: a v4 blob (pre-S8/S9 reader {mode, selection, zones}, no consumed marker):
|
||||||
@@ -512,7 +594,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes)
|
|||||||
const std::uint32_t idLen = r.u32();
|
const std::uint32_t idLen = r.u32();
|
||||||
out.selectionId = r.str(idLen);
|
out.selectionId = r.str(idLen);
|
||||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||||
readZonesPayload(r, out.map);
|
readZonesPayload(r, out.map, projectRate);
|
||||||
return out; // marker stays 0 (pre-S8/S9 reader)
|
return out; // marker stays 0 (pre-S8/S9 reader)
|
||||||
}
|
}
|
||||||
if (version != kComponentStateVersion) return out; // unknown -> empty
|
if (version != kComponentStateVersion) return out; // unknown -> empty
|
||||||
@@ -528,7 +610,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes)
|
|||||||
const std::uint32_t idLen = r.u32();
|
const std::uint32_t idLen = r.u32();
|
||||||
out.selectionId = r.str(idLen);
|
out.selectionId = r.str(idLen);
|
||||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||||
readZonesPayload(r, out.map);
|
readZonesPayload(r, out.map, projectRate);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+114
-41
@@ -107,6 +107,51 @@ std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleav
|
|||||||
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
|
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
|
||||||
int channelCount, int which);
|
int channelCount, int which);
|
||||||
|
|
||||||
|
// --- Stored (wall-clock SECONDS) per-zone play params -------------------------
|
||||||
|
//
|
||||||
|
// DOMAIN SPLIT (S12 remediation — Daniel's ruling: no hardcoded sample rate in the program).
|
||||||
|
// The instrument stores and edits WALL-CLOCK performance times as SECONDS, rate-free; the
|
||||||
|
// engine (sampler_core's ZonePlayParams, on SampleData) receives FRAMES resolved from the
|
||||||
|
// LIVE sample rate at keymap build. AHDSR (A/H/D/S/R) and the AD pitch envelope (attack/decay)
|
||||||
|
// are wall-clock — the voice advances them once per OUTPUT frame — so they live here in seconds.
|
||||||
|
// Quantities anchored to the source file's timeline (start point, loop points, Trigger %-length
|
||||||
|
// and its fades — the fades anchor to the source-frame read offset, PLAN.md §S15) stay in source
|
||||||
|
// frames / fractions and are carried through unchanged (TriggerParams is reused verbatim).
|
||||||
|
//
|
||||||
|
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
|
||||||
|
struct AdsrSeconds {
|
||||||
|
double attackSeconds = 0.003; // tier-0 default
|
||||||
|
double holdSeconds = 0.0;
|
||||||
|
double decaySeconds = 0.0;
|
||||||
|
double sustainLevel = 1.0;
|
||||||
|
double releaseSeconds = 0.060; // tier-0 default
|
||||||
|
};
|
||||||
|
|
||||||
|
// The stored AD pitch-envelope times (seconds). enabled + peakSemitones are dimensionless.
|
||||||
|
struct PitchEnvSeconds {
|
||||||
|
bool enabled = false;
|
||||||
|
double attackSeconds = 0.0;
|
||||||
|
double decaySeconds = 0.0;
|
||||||
|
double peakSemitones = 0.0; // signed depth at the peak
|
||||||
|
};
|
||||||
|
|
||||||
|
// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities in
|
||||||
|
// frames/fractions (TriggerParams). This is the instrument-owned (D-B), serialized, editor-facing
|
||||||
|
// representation — distinct from sampler_core's engine-facing ZonePlayParams (frames). The keymap
|
||||||
|
// builders resolve this to a frame-domain ZonePlayParams against the live sample rate.
|
||||||
|
struct ZonePlaySeconds {
|
||||||
|
PlayMode playMode = PlayMode::Gate;
|
||||||
|
AdsrSeconds adsr; // Gate: AHDSR (seconds)
|
||||||
|
TriggerParams trigger; // Trigger: %-length + fades (source frames)
|
||||||
|
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve (S16-F1)
|
||||||
|
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve a stored seconds bundle to the engine's frame-domain ZonePlayParams against a live
|
||||||
|
// sample rate (frames = round(seconds * rate)). Source-timeline fields (trigger, engine, mode,
|
||||||
|
// peak, enabled) carry through unchanged. `sampleRate` must be > 0 (the caller guards this).
|
||||||
|
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate);
|
||||||
|
|
||||||
// Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole
|
// Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole
|
||||||
// keyboard, repitched from `rootNote`, looped per `loop`. The single-sample degenerate case
|
// keyboard, repitched from `rootNote`, looped per `loop`. The single-sample degenerate case
|
||||||
// (Keymap::singleSampleChromatic) with the S2 intrinsics threaded in. `frames` is channel 0
|
// (Keymap::singleSampleChromatic) with the S2 intrinsics threaded in. `frames` is channel 0
|
||||||
@@ -114,16 +159,14 @@ std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interlea
|
|||||||
// which yields a mono SampleData byte-identical to the pre-S7 build. A `framesR` whose length
|
// which yields a mono SampleData byte-identical to the pre-S7 build. A `framesR` whose length
|
||||||
// mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad
|
// mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad
|
||||||
// pair never half-plays. `sampleRate` is the WAV's rate.
|
// pair never half-plays. `sampleRate` is the WAV's rate.
|
||||||
// `play` carries the S15/S16 per-zone play params for the single-capture path; it defaults to
|
// `play` carries the S15/S16 per-zone play params (SECONDS) for the single-capture path; it
|
||||||
// the PRODUCT defaults (Gate + Preserve engine, S16-F1) so a picked single capture plays under
|
// defaults to the PRODUCT defaults (Gate + tier-0 AHDSR seconds + Preserve engine, S16-F1) so a
|
||||||
// the same default engine as a zone would. The editor will surface per-capture overrides later
|
// picked single capture plays under the same default engine as a zone would. This function
|
||||||
// (S15-F2 one-zone-map lean); until then this is the one place the single-capture default lives.
|
// resolves the wall-clock seconds to frames against `sampleRate` before stamping the SampleData.
|
||||||
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
|
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
|
||||||
int rootNote, const SampleLoop& loop,
|
int rootNote, const SampleLoop& loop,
|
||||||
std::vector<AudioSample> framesR = {},
|
std::vector<AudioSample> framesR = {},
|
||||||
const ZonePlayParams& play = ZonePlayParams{
|
const ZonePlaySeconds& play = ZonePlaySeconds{});
|
||||||
PlayMode::Gate, AdsrParams{}, TriggerParams{}, kDefaultPitchEngine,
|
|
||||||
PitchEnvParams{}});
|
|
||||||
|
|
||||||
// --- Performance map (Tier 1, D-B: the instrument's OWN state) ---------------
|
// --- Performance map (Tier 1, D-B: the instrument's OWN state) ---------------
|
||||||
//
|
//
|
||||||
@@ -155,16 +198,14 @@ struct PerformanceZone {
|
|||||||
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
|
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
|
||||||
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
|
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
|
||||||
|
|
||||||
// S15/S16 per-zone play parameters (play mode + AHDSR hold + Trigger %-length/fades; pitch
|
// S15/S16 per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch
|
||||||
// engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the
|
// engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the
|
||||||
// loop/start overrides. Defaults to the PRODUCT defaults for a NEW zone: Gate play mode,
|
// loop/start overrides. Wall-clock times are stored in SECONDS (rate-free); the keymap build
|
||||||
// hold 0, no fades, and the PRESERVE pitch engine (S16-F1 — Daniel's directive; the one
|
// resolves them to frames at the live sample rate. Defaults to the PRODUCT defaults for a NEW
|
||||||
// flippable default is sampler_core::kDefaultPitchEngine), pitch envelope off. An older
|
// zone: Gate play mode, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades,
|
||||||
// zone-payload blob (no S15/S16 tail) lifts to exactly these defaults on read (see the
|
// PRESERVE pitch engine (S16-F1), pitch env off. An older zone-payload blob (no S15/S16 tail)
|
||||||
// PAYLOAD v3 versioning in the (de)serialize section), so a pre-S15 instrument opens with
|
// lifts to exactly these defaults on read (see the PAYLOAD versioning).
|
||||||
// Gate + Preserve — the deliberate, spec-flagged behavior change.
|
ZonePlaySeconds play;
|
||||||
ZonePlayParams play{PlayMode::Gate, AdsrParams{}, TriggerParams{}, kDefaultPitchEngine,
|
|
||||||
PitchEnvParams{}};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The instrument's performance map: an ordered list of zones. Order is authoritative for
|
// The instrument's performance map: an ordered list of zones. Order is authoritative for
|
||||||
@@ -188,8 +229,7 @@ struct ResolvedZone {
|
|||||||
int rootNote = 60; // effective: override, else bank intrinsic, else 60
|
int rootNote = 60; // effective: override, else bank intrinsic, else 60
|
||||||
SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11)
|
SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11)
|
||||||
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11)
|
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11)
|
||||||
ZonePlayParams play{PlayMode::Gate, AdsrParams{}, TriggerParams{}, kDefaultPitchEngine,
|
ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build)
|
||||||
PitchEnvParams{}}; // S15/S16 per-zone play params (carried through as-is)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The result of resolving a performance map against the live bank blob. `zones` are the
|
// The result of resolving a performance map against the live bank blob. `zones` are the
|
||||||
@@ -220,7 +260,8 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
|||||||
// map). Empty zones in -> empty Keymap (silence).
|
// map). Empty zones in -> empty Keymap (silence).
|
||||||
struct DecodedZonePcm {
|
struct DecodedZonePcm {
|
||||||
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
|
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
|
||||||
int sampleRate = 44100;
|
int sampleRate = 0; // 0 is explicitly invalid; every consumer must
|
||||||
|
// receive the WAV's real rate before use.
|
||||||
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
|
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
|
||||||
};
|
};
|
||||||
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||||
@@ -269,20 +310,39 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
|||||||
// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64).
|
// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64).
|
||||||
// The reader detects the marker to know the record shape — a v1 payload (no marker) reads
|
// The reader detects the marker to know the record shape — a v1 payload (no marker) reads
|
||||||
// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope.
|
// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope.
|
||||||
// * PAYLOAD v3 (S15/S16): the same marker + payload version (== 3), THEN the v2 body PLUS,
|
// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload
|
||||||
// appended to each zone record after the S11 startPoint tail (the S15/S16 per-zone play
|
// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint
|
||||||
// params — always present, NOT flag-gated, since every zone has a play mode + engine):
|
// tail (the S15/S16 per-zone play params — always present, NOT flag-gated):
|
||||||
// 1 byte playMode (0 = Gate, 1 = Trigger);
|
// 1 byte playMode (0 = Gate, 1 = Trigger);
|
||||||
// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage (A/D/S/R timing stays
|
// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal;
|
||||||
// instrument-wide; only hold is per-zone);
|
|
||||||
// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE);
|
// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE);
|
||||||
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
|
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
|
||||||
// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve);
|
// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve);
|
||||||
// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64);
|
// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom);
|
||||||
// 8-byte LE pitchEnv.decayFrames (int64); 8-byte LE pitchEnv.peakSemitones as a double.
|
// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double.
|
||||||
// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve +
|
// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve +
|
||||||
// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved
|
// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved
|
||||||
// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest.
|
// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest.
|
||||||
|
// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS
|
||||||
|
// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds
|
||||||
|
// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed
|
||||||
|
// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames.
|
||||||
|
// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060).
|
||||||
|
// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5),
|
||||||
|
// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full
|
||||||
|
// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles):
|
||||||
|
// 1 byte playMode (0 = Gate, 1 = Trigger);
|
||||||
|
// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double);
|
||||||
|
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
|
||||||
|
// 1 byte pitchEngine; 1 byte pitchEnv.enabled;
|
||||||
|
// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double);
|
||||||
|
// 8-byte LE pitchEnv.peakSemitones (double);
|
||||||
|
// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double);
|
||||||
|
// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double).
|
||||||
|
// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4
|
||||||
|
// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader
|
||||||
|
// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds
|
||||||
|
// to frames at the LIVE sample rate; no rate is baked into storage or the program.
|
||||||
// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is
|
// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is
|
||||||
// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved
|
// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved
|
||||||
// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes
|
// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes
|
||||||
@@ -295,22 +355,31 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
|||||||
|
|
||||||
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
||||||
|
|
||||||
// The zones-payload format version and its detection marker (S11/S15/S16). serializePerformance
|
// The zones-payload format version and its detection marker (S11/S15/S16/S12). serializePerformance
|
||||||
// and serializeComponentState both emit the CURRENT payload version (v3 — marker + version +
|
// and serializeComponentState both emit the CURRENT payload version (v5 — marker + version +
|
||||||
// records with the S11 loop/start tail AND the S15/S16 play-params tail) so the overrides
|
// records with the S11 loop/start tail AND the full play-params tail with wall-clock times in
|
||||||
// round-trip through EITHER envelope. Readers accept a v1 payload (no marker) and a v2 payload
|
// SECONDS) so the overrides round-trip through EITHER envelope. Readers accept a v1 payload (no
|
||||||
// (marker + version 2, no play tail) for back-compat, lifting the missing fields to defaults.
|
// marker), a v2 payload (marker + version 2, no play tail), and a v3 payload (legacy S15/S16
|
||||||
// The marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in
|
// play tail with wall-clock frame counts) for back-compat, lifting missing fields to defaults.
|
||||||
// practice, always tiny) can never collide with.
|
// v4 was never shipped and is not read. The marker is a high sentinel that a legitimate zone
|
||||||
inline constexpr std::uint32_t kZonesPayloadVersion = 3; // S15/S16: per-zone play params tail
|
// count (bounded by 128 MIDI zones in practice, always tiny) can never collide with.
|
||||||
|
inline constexpr std::uint32_t kZonesPayloadVersion = 5; // S12: full per-zone play params, SECONDS
|
||||||
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
|
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
|
||||||
|
|
||||||
|
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are
|
||||||
|
// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a
|
||||||
|
// parameter — frames ÷ projectRate = seconds. The project rate is the same rate keymap build
|
||||||
|
// already receives, so the seconds domain is consistent across both paths. No constant is baked in.
|
||||||
|
|
||||||
// The performance map serialized to bytes for IBStream (getState).
|
// The performance map serialized to bytes for IBStream (getState).
|
||||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
|
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
|
||||||
|
|
||||||
// The performance map parsed back from IBStream bytes (setState). A v2 blob parses
|
// The performance map parsed back from IBStream bytes (setState). A v2 blob parses
|
||||||
// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map.
|
// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map.
|
||||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes);
|
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
|
||||||
|
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
|
||||||
|
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||||
|
double projectRate);
|
||||||
|
|
||||||
// --- Combined component state (VST3 setState/getState, v3 — S10) -------------
|
// --- Combined component state (VST3 setState/getState, v3 — S10) -------------
|
||||||
//
|
//
|
||||||
@@ -321,12 +390,13 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes);
|
|||||||
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
|
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
|
||||||
// state), never auto-playing sample #1.
|
// state), never auto-playing sample #1.
|
||||||
//
|
//
|
||||||
// Format (v5): 4-byte LE version tag (== 5), then a 1-byte channel-mode field (0 = mono,
|
// Format (envelope v5): 4-byte LE version tag (== 5), then a 1-byte channel-mode field (0 = mono,
|
||||||
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker),
|
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker),
|
||||||
// then a 4-byte LE selection-id length + id bytes, then the v2 zones payload (4-byte LE zone
|
// then a 4-byte LE selection-id length + id bytes, then the CURRENT zones payload (identical to
|
||||||
// count + per-zone records, identical to serializePerformance's body). The 8-byte marker is
|
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
|
||||||
// the ONLY v5 addition over v4 — the envelope grew a field, the zones payload is untouched
|
// The 8-byte marker is the ONLY envelope-v5 addition over envelope-v4 — the envelope grew a field,
|
||||||
// (a PARALLEL track owns zone-record extension under the map's own versioning). BACK-COMPAT on
|
// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own
|
||||||
|
// versioning; the two version numbers are independent axes). BACK-COMPAT on
|
||||||
// read (every older blob lifts to channelMode = MONO and lastConsumedAssignGeneration = 0,
|
// read (every older blob lifts to channelMode = MONO and lastConsumedAssignGeneration = 0,
|
||||||
// preserving current behavior for already-saved instances):
|
// preserving current behavior for already-saved instances):
|
||||||
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, selectionId, zones} direct.
|
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, selectionId, zones} direct.
|
||||||
@@ -366,7 +436,10 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state);
|
|||||||
// The full instance state parsed back from IBStream bytes (setState). Tolerant of
|
// 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
|
// truncation/wrong-version (bounded reads, never throws); older blobs lift per the table
|
||||||
// above so already-saved instances restore cleanly.
|
// above so already-saved instances restore cleanly.
|
||||||
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes);
|
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
|
||||||
|
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
|
||||||
|
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||||
|
double projectRate);
|
||||||
|
|
||||||
// --- Instance state (VST3 setState/getState) --------------------------------
|
// --- Instance state (VST3 setState/getState) --------------------------------
|
||||||
//
|
//
|
||||||
|
|||||||
+15
-10
@@ -249,8 +249,7 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
|||||||
shiftR_.configure(windowFrames);
|
shiftR_.configure(windowFrames);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
|
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote) {
|
||||||
const AdsrParams& gateAdsr) {
|
|
||||||
active_ = true;
|
active_ = true;
|
||||||
releasing_ = false;
|
releasing_ = false;
|
||||||
amplitudeDone_ = false;
|
amplitudeDone_ = false;
|
||||||
@@ -276,12 +275,18 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
|
|||||||
readPos_ = static_cast<double>(start);
|
readPos_ = static_cast<double>(start);
|
||||||
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
|
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
|
||||||
|
|
||||||
// --- Amplitude envelope: Gate = AHDSR (instrument A/D/S/R + per-zone HOLD); Trigger = the
|
// --- Amplitude envelope: Gate = AHDSR (fully per-zone: A/H/D/S/R all read from the zone's
|
||||||
// time-boxed fade-in/out over the % play length. ---
|
// play.adsr); Trigger = the time-boxed fade-in/out over the % play length.
|
||||||
|
//
|
||||||
|
// All five AHDSR fields come from sample.play.adsr (in FRAMES), resolved by
|
||||||
|
// buildTier0Keymap / buildZonedKeymap at reload time from the stored SECONDS against
|
||||||
|
// the live sample rate.
|
||||||
|
//
|
||||||
|
// Back-compat invariant: a zone whose stored ADSR seconds carry the tier-0 defaults
|
||||||
|
// (resolved to frames at the live sample rate) sounds identical to the pre-S12 build at
|
||||||
|
// every DAW rate — now trivially true, since the times are wall-clock seconds. ---
|
||||||
if (playMode_ == PlayMode::Gate) {
|
if (playMode_ == PlayMode::Gate) {
|
||||||
AdsrParams a = gateAdsr;
|
env_.configure(p.adsr);
|
||||||
a.holdFrames = p.adsr.holdFrames; // per-zone hold folds into the instrument-wide AHDSR
|
|
||||||
env_.configure(a);
|
|
||||||
env_.noteOn();
|
env_.noteOn();
|
||||||
playEnd_ = 0; // unused in Gate
|
playEnd_ = 0; // unused in Gate
|
||||||
} else {
|
} else {
|
||||||
@@ -480,9 +485,9 @@ void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
||||||
const AdsrParams& adsr, std::size_t preserveVoiceCap,
|
std::size_t preserveVoiceCap,
|
||||||
std::int64_t preserveWindowFrames)
|
std::int64_t preserveWindowFrames)
|
||||||
: voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap), adsr_(adsr),
|
: voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap),
|
||||||
preserveVoiceCap_(preserveVoiceCap) {
|
preserveVoiceCap_(preserveVoiceCap) {
|
||||||
// maxVoices == 0 would mean "no polyphony at all", which cannot service a note-on;
|
// maxVoices == 0 would mean "no polyphony at all", which cannot service a note-on;
|
||||||
// clamp to a single voice so the engine is always usable (documented degenerate).
|
// clamp to a single voice so the engine is always usable (documented degenerate).
|
||||||
@@ -553,7 +558,7 @@ std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
|||||||
// The voice's Preserve shifters were pre-sized at engine construction (off-thread), so
|
// The voice's Preserve shifters were pre-sized at engine construction (off-thread), so
|
||||||
// start() only reset()s + warm()s them — no allocation on this audio-thread path.
|
// start() only reset()s + warm()s them — no allocation on this audio-thread path.
|
||||||
const std::size_t v = allocateVoice();
|
const std::size_t v = allocateVoice();
|
||||||
voices_[v].start(note, velocity, sample, zone.rootNote, adsr_);
|
voices_[v].start(note, velocity, sample, zone.rootNote);
|
||||||
voices_[v].setStartOrder(nextStartOrder_++);
|
voices_[v].setStartOrder(nextStartOrder_++);
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-26
@@ -145,8 +145,10 @@ struct SampleLoop {
|
|||||||
struct SampleData {
|
struct SampleData {
|
||||||
std::vector<AudioSample> frames; // channel 0 PCM (mono, or L of a stereo sample)
|
std::vector<AudioSample> frames; // channel 0 PCM (mono, or L of a stereo sample)
|
||||||
std::vector<AudioSample> framesR; // channel 1 PCM (R); EMPTY for a mono sample
|
std::vector<AudioSample> framesR; // channel 1 PCM (R); EMPTY for a mono sample
|
||||||
int sampleRate = 44100; // frames per second (for reference; ratio is
|
int sampleRate = 0; // frames per second (for reference; ratio is
|
||||||
// note-relative, so rate cancels for repitch)
|
// note-relative, so rate cancels for repitch).
|
||||||
|
// 0 is explicitly invalid — every consumer must
|
||||||
|
// receive a real rate before use.
|
||||||
int rootNote = 60; // MIDI note recorded at (plays at unity here)
|
int rootNote = 60; // MIDI note recorded at (plays at unity here)
|
||||||
SampleLoop loop; // sustain loop, if any
|
SampleLoop loop; // sustain loop, if any
|
||||||
// Initial read position (frame offset) a voice starts playback at — frame 0 by
|
// Initial read position (frame offset) a voice starts playback at — frame 0 by
|
||||||
@@ -340,17 +342,16 @@ class Voice {
|
|||||||
public:
|
public:
|
||||||
// Starts this voice on `note` at `velocity`, playing `sample` (a stable reference
|
// Starts this voice on `note` at `velocity`, playing `sample` (a stable reference
|
||||||
// the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched
|
// the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched
|
||||||
// from `rootNote`. `gateAdsr` is the effective Gate AHDSR (the engine supplies the
|
// from `rootNote`. All five AHDSR fields (A/H/D/S/R) are read directly from
|
||||||
// instrument-wide attack/decay/sustain/release timing; the per-zone HOLD stage comes from
|
// sample.play.adsr — the per-zone values (in FRAMES) resolved from the stored seconds by
|
||||||
// sample.play.adsr.holdFrames, folded in here). The S15 play MODE + Trigger params and the
|
// buildTier0Keymap / buildZonedKeymap against the live sample rate. The S15 play MODE +
|
||||||
// S16 pitch ENGINE + pitch envelope are read from `sample.play`. The Preserve shifters MUST
|
// Trigger params and the S16 pitch ENGINE + pitch envelope are read from `sample.play`.
|
||||||
// already be pre-sized (presizePreserveShifters, off-thread) — start() only reset()s + warm()s
|
// The Preserve shifters MUST already be pre-sized (presizePreserveShifters, off-thread) —
|
||||||
// them (RT-safe, no allocation) since it runs on the audio thread inside process(). The warm
|
// start() only reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio
|
||||||
// silence pass settles the OLA taps before the first output frame (no cold-start click).
|
// thread inside process(). The warm silence pass settles the OLA taps before the first
|
||||||
// Byte-identical to the pre-S15 engine when sample.play is default (Gate + Varispeed + no
|
// output frame (no cold-start click). Byte-identical to the pre-S15 engine when sample.play
|
||||||
// pitch env).
|
// is default (Gate + Varispeed + no pitch env).
|
||||||
void start(int note, int velocity, const SampleData& sample, int rootNote,
|
void start(int note, int velocity, const SampleData& sample, int rootNote);
|
||||||
const AdsrParams& gateAdsr);
|
|
||||||
|
|
||||||
// Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in
|
// Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in
|
||||||
// TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length).
|
// TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length).
|
||||||
@@ -454,18 +455,19 @@ class VoiceEngine {
|
|||||||
public:
|
public:
|
||||||
// Builds an engine with `maxVoices` voices (the polyphony bound) playing from
|
// Builds an engine with `maxVoices` voices (the polyphony bound) playing from
|
||||||
// `keymap`. The keymap must outlive the engine (the engine holds a reference — it
|
// `keymap`. The keymap must outlive the engine (the engine holds a reference — it
|
||||||
// reads zones and sample data through it, never copies PCM). `adsr` is the instrument-wide
|
// reads zones and sample data through it, never copies PCM). Every AHDSR field (A/H/D/S/R)
|
||||||
// Gate AHDSR timing (attack/decay/sustain/release); each zone's HOLD stage + play mode +
|
// + play mode + pitch engine rides on each zone's SampleData::play (in FRAMES, resolved
|
||||||
// pitch engine ride on its SampleData::play. `preserveVoiceCap` (S16) bounds how many
|
// from the stored seconds at keymap build); the engine holds no instrument-wide ADSR.
|
||||||
// Preserve-engine voices may sound at once (the shifter is materially heavier than
|
// `preserveVoiceCap` (S16) bounds how many Preserve-engine voices may sound at once (the
|
||||||
// Varispeed) — a Preserve note-on beyond the cap is dropped rather than glitching; 0 means
|
// shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is
|
||||||
// "no separate Preserve cap" (bounded only by maxVoices). `preserveWindowFrames` is the OLA
|
// dropped rather than glitching; 0 means "no separate Preserve cap" (bounded only by
|
||||||
// window (in OUTPUT frames) every voice's Preserve pitch shifters are PRE-SIZED to at
|
// maxVoices). `preserveWindowFrames` is the OLA window (in OUTPUT frames) every voice's
|
||||||
// construction (OFF the audio thread), so note-on (which runs in process()) never allocates;
|
// Preserve pitch shifters are PRE-SIZED to at construction (OFF the audio thread), so
|
||||||
// 0 leaves them pass-through (a Varispeed-only instrument pays no ring cost). The processor
|
// note-on (which runs in process()) never allocates; 0 leaves them pass-through (a
|
||||||
// derives it from the host sample rate (kPreserveWindowMs). Defaulted so existing callers
|
// Varispeed-only instrument pays no ring cost). The processor derives it from the host
|
||||||
// (and the pure-core tests) are unaffected.
|
// sample rate (kPreserveWindowMs). Defaulted so existing callers (and the pure-core tests)
|
||||||
VoiceEngine(std::size_t maxVoices, const Keymap& keymap, const AdsrParams& adsr,
|
// are unaffected.
|
||||||
|
VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
||||||
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0);
|
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0);
|
||||||
|
|
||||||
// MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of
|
// MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of
|
||||||
@@ -522,7 +524,6 @@ private:
|
|||||||
|
|
||||||
std::vector<Voice> voices_;
|
std::vector<Voice> voices_;
|
||||||
const Keymap& keymap_;
|
const Keymap& keymap_;
|
||||||
AdsrParams adsr_;
|
|
||||||
std::size_t preserveVoiceCap_ = 0; // S16: max simultaneous Preserve voices (0 = no separate cap)
|
std::size_t preserveVoiceCap_ = 0; // S16: max simultaneous Preserve voices (0 = no separate cap)
|
||||||
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
|
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// Standalone tests for reasampler::vst::browser_scroll — no VST3, no REAPER, no framework.
|
||||||
|
// Same fast assert loop as the sibling pure editor tests: assert the S12 scroll-window +
|
||||||
|
// scrollbar-thumb + type-to-filter-search geometry LAYERED over the S10 capture_browser.
|
||||||
|
//
|
||||||
|
// Covers: scrollContentHeight (ceil rows * card height, 0 for no cards); scrollMaxOffset (0
|
||||||
|
// when content fits, else content-visible); clampScrollOffset pinning to [0,max]; visibleCardRange
|
||||||
|
// windowing (top rows only, scrolled window, empty when scrolled past the end); scrolledCardCellRect
|
||||||
|
// shifting a cell up by the offset; scrollThumbRect (empty when it fits, proportional height +
|
||||||
|
// position, minimum height, at-max pins to the track bottom); thumbDragToOffset as the position
|
||||||
|
// inverse (a full-track drag reaches max, round-trips); searchBoxRect; nameMatchesQuery
|
||||||
|
// (case-insensitive substring, empty-query identity, no-match); filterNameIndices preserving order
|
||||||
|
// and returning every index for an empty query.
|
||||||
|
|
||||||
|
#include "../src/vst/browser_scroll.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace reasampler::vst;
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
|
// A layout wide enough for a few columns and tall enough to show a few rows.
|
||||||
|
static BrowserLayout wideLayout() { return layoutBrowser(560, 300); }
|
||||||
|
|
||||||
|
// --- content / max / clamp ----------------------------------------------------
|
||||||
|
|
||||||
|
static void testContentHeight() {
|
||||||
|
const BrowserLayout L = wideLayout();
|
||||||
|
CHECK(scrollContentHeight(L, 0) == 0);
|
||||||
|
// One card -> one row -> one card height.
|
||||||
|
CHECK(scrollContentHeight(L, 1) == kBrowserCardHeight);
|
||||||
|
// columns+1 cards -> two rows.
|
||||||
|
CHECK(scrollContentHeight(L, L.columns + 1) == 2 * kBrowserCardHeight);
|
||||||
|
// Exactly `columns` cards -> one row.
|
||||||
|
CHECK(scrollContentHeight(L, L.columns) == kBrowserCardHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testMaxOffsetFitsAndOverflows() {
|
||||||
|
const BrowserLayout L = wideLayout();
|
||||||
|
// A single row fits within the 300px area -> no scroll.
|
||||||
|
CHECK(scrollMaxOffset(L, L.columns) == 0);
|
||||||
|
// Many rows overflow -> max = content - gridHeight.
|
||||||
|
const int many = L.columns * 20;
|
||||||
|
const int expect = scrollContentHeight(L, many) - L.grid.height();
|
||||||
|
CHECK(scrollMaxOffset(L, many) == expect);
|
||||||
|
CHECK(expect > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testClamp() {
|
||||||
|
const BrowserLayout L = wideLayout();
|
||||||
|
const int many = L.columns * 20;
|
||||||
|
const int maxOff = scrollMaxOffset(L, many);
|
||||||
|
CHECK(clampScrollOffset(L, many, -50) == 0);
|
||||||
|
CHECK(clampScrollOffset(L, many, maxOff + 500) == maxOff);
|
||||||
|
CHECK(clampScrollOffset(L, many, maxOff / 2) == maxOff / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- visible window -----------------------------------------------------------
|
||||||
|
|
||||||
|
static void testVisibleRangeTop() {
|
||||||
|
const BrowserLayout L = wideLayout();
|
||||||
|
const int many = L.columns * 20;
|
||||||
|
const VisibleRange vr = visibleCardRange(L, many, 0);
|
||||||
|
CHECK(vr.first == 0);
|
||||||
|
// At offset 0, the last visible row is the one containing (gridH-1).
|
||||||
|
const int expectedLastRow = (L.grid.height() - 1) / kBrowserCardHeight + 1;
|
||||||
|
CHECK(vr.last == expectedLastRow * L.columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testVisibleRangeScrolled() {
|
||||||
|
const BrowserLayout L = wideLayout();
|
||||||
|
const int many = L.columns * 20;
|
||||||
|
// Scroll one full card row down.
|
||||||
|
const VisibleRange vr = visibleCardRange(L, many, kBrowserCardHeight);
|
||||||
|
CHECK(vr.first == L.columns); // the first row scrolled off the top
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testVisibleRangeEmptyWhenNoCards() {
|
||||||
|
const BrowserLayout L = wideLayout();
|
||||||
|
const VisibleRange vr = visibleCardRange(L, 0, 0);
|
||||||
|
CHECK(vr.first == 0 && vr.last == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testScrolledCellShiftsUp() {
|
||||||
|
const BrowserLayout L = wideLayout();
|
||||||
|
const Rect base = cardCellRect(L, 3);
|
||||||
|
const Rect shifted = scrolledCardCellRect(L, 3, 40);
|
||||||
|
CHECK(shifted.top == base.top - 40);
|
||||||
|
CHECK(shifted.bottom == base.bottom - 40);
|
||||||
|
CHECK(shifted.left == base.left);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- scrollbar thumb ----------------------------------------------------------
|
||||||
|
|
||||||
|
static void testThumbEmptyWhenFits() {
|
||||||
|
const BrowserLayout L = wideLayout();
|
||||||
|
CHECK(scrollThumbRect(L, L.columns, 0).height() == 0); // one row fits -> no thumb
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testThumbProportionalAndClamped() {
|
||||||
|
const BrowserLayout L = wideLayout();
|
||||||
|
const int many = L.columns * 20;
|
||||||
|
const Rect atTop = scrollThumbRect(L, many, 0);
|
||||||
|
CHECK(atTop.height() > 0);
|
||||||
|
CHECK(atTop.top == L.grid.top); // at offset 0 the thumb starts at the track top
|
||||||
|
CHECK(atTop.width() == kScrollbarWidth);
|
||||||
|
CHECK(atTop.right == L.grid.right);
|
||||||
|
// At max offset, the thumb bottom reaches the grid bottom (pinned to the end).
|
||||||
|
const int maxOff = scrollMaxOffset(L, many);
|
||||||
|
const Rect atMax = scrollThumbRect(L, many, maxOff);
|
||||||
|
CHECK(atMax.bottom == L.grid.top + L.grid.height());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testThumbDragIsInverse() {
|
||||||
|
const BrowserLayout L = wideLayout();
|
||||||
|
const int many = L.columns * 20;
|
||||||
|
const int maxOff = scrollMaxOffset(L, many);
|
||||||
|
// A zero drag holds the start offset.
|
||||||
|
CHECK(thumbDragToOffset(L, many, 0, 0) == 0);
|
||||||
|
// A large positive drag pins to max; a large negative drag pins to 0.
|
||||||
|
CHECK(thumbDragToOffset(L, many, 0, 100000) == maxOff);
|
||||||
|
CHECK(thumbDragToOffset(L, many, maxOff, -100000) == 0);
|
||||||
|
// Dragging the thumb by the whole track span from top reaches (near) max.
|
||||||
|
const Rect thumb = scrollThumbRect(L, many, 0);
|
||||||
|
const int trackSpan = L.grid.height() - thumb.height();
|
||||||
|
const int off = thumbDragToOffset(L, many, 0, trackSpan);
|
||||||
|
CHECK(off >= maxOff - 2 && off <= maxOff);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- search -------------------------------------------------------------------
|
||||||
|
|
||||||
|
static void testSearchBoxRect() {
|
||||||
|
const Rect r = searchBoxRect(200);
|
||||||
|
CHECK(r.left == 0 && r.top == 0 && r.right == 200 && r.height() == kSearchBoxHeight);
|
||||||
|
CHECK(searchBoxRect(0).width() == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testNameMatch() {
|
||||||
|
CHECK(nameMatchesQuery("Kick Drum 01", "")); // empty query matches all
|
||||||
|
CHECK(nameMatchesQuery("Kick Drum 01", "drum")); // case-insensitive substring
|
||||||
|
CHECK(nameMatchesQuery("Kick Drum 01", "KICK"));
|
||||||
|
CHECK(!nameMatchesQuery("Kick Drum 01", "snare"));
|
||||||
|
CHECK(!nameMatchesQuery("ab", "abc")); // query longer than name
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testFilterIndices() {
|
||||||
|
std::vector<std::string> names{"Kick", "Snare", "Kick Sub", "Hat"};
|
||||||
|
// Empty query -> every index, in order.
|
||||||
|
const std::vector<int> all = filterNameIndices(names, "");
|
||||||
|
CHECK(all.size() == 4 && all[0] == 0 && all[3] == 3);
|
||||||
|
// "kick" -> indices 0 and 2, order preserved.
|
||||||
|
const std::vector<int> kicks = filterNameIndices(names, "kick");
|
||||||
|
CHECK(kicks.size() == 2 && kicks[0] == 0 && kicks[1] == 2);
|
||||||
|
// No match -> empty.
|
||||||
|
CHECK(filterNameIndices(names, "zzz").empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testContentHeight();
|
||||||
|
testMaxOffsetFitsAndOverflows();
|
||||||
|
testClamp();
|
||||||
|
testVisibleRangeTop();
|
||||||
|
testVisibleRangeScrolled();
|
||||||
|
testVisibleRangeEmptyWhenNoCards();
|
||||||
|
testScrolledCellShiftsUp();
|
||||||
|
testThumbEmptyWhenFits();
|
||||||
|
testThumbProportionalAndClamped();
|
||||||
|
testThumbDragIsInverse();
|
||||||
|
testSearchBoxRect();
|
||||||
|
testNameMatch();
|
||||||
|
testFilterIndices();
|
||||||
|
|
||||||
|
if (g_fail == 0) std::printf("browser_scroll: all tests passed\n");
|
||||||
|
return g_fail != 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Standalone tests for reasampler::vst::note_entry — no VST3, no REAPER, no framework.
|
||||||
|
// Assert the S12 direct-numeric-entry parse for a zone's low/high/root MIDI note.
|
||||||
|
//
|
||||||
|
// Covers: plain decimal integers (with +/- sign + surrounding whitespace); note names under the
|
||||||
|
// C4==60 convention (C-1==0, sharps + flats, negative octaves); out-of-range values CLAMPING to
|
||||||
|
// [0,127] rather than rejecting; empty / whitespace-only / unparseable input returning nullopt;
|
||||||
|
// the integer path taking precedence over the note-name path for a leading digit.
|
||||||
|
|
||||||
|
#include "../src/vst/note_entry.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
using namespace reasampler::vst;
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
|
static void testPlainIntegers() {
|
||||||
|
CHECK(parseNoteEntry("60") == 60);
|
||||||
|
CHECK(parseNoteEntry("0") == 0);
|
||||||
|
CHECK(parseNoteEntry("127") == 127);
|
||||||
|
CHECK(parseNoteEntry(" 64 ") == 64); // surrounding whitespace ignored
|
||||||
|
CHECK(parseNoteEntry("+5") == 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testIntegerClamps() {
|
||||||
|
CHECK(parseNoteEntry("200") == 127); // over-range clamps to the ceiling
|
||||||
|
CHECK(parseNoteEntry("-10") == 0); // under-range clamps to the floor
|
||||||
|
CHECK(parseNoteEntry("99999") == 127);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testNoteNames() {
|
||||||
|
// C4 == 60 (MIDI 0 == C-1).
|
||||||
|
CHECK(parseNoteEntry("C4") == 60);
|
||||||
|
CHECK(parseNoteEntry("c4") == 60); // case-insensitive
|
||||||
|
CHECK(parseNoteEntry("A4") == 69); // A4 = 69 (concert A)
|
||||||
|
CHECK(parseNoteEntry("C-1") == 0); // lowest MIDI note
|
||||||
|
CHECK(parseNoteEntry("G9") == 127); // G9 = 127
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testAccidentals() {
|
||||||
|
CHECK(parseNoteEntry("C#4") == 61);
|
||||||
|
CHECK(parseNoteEntry("Db4") == 61); // enharmonic of C#4
|
||||||
|
CHECK(parseNoteEntry("F#3") == 54);
|
||||||
|
CHECK(parseNoteEntry("Bb3") == 58); // Bb3 = 58
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testNoteNameClamps() {
|
||||||
|
CHECK(parseNoteEntry("C10") == 127); // above the range clamps
|
||||||
|
CHECK(parseNoteEntry("C-5") == 0); // below the range clamps
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRejects() {
|
||||||
|
CHECK(parseNoteEntry("") == std::nullopt);
|
||||||
|
CHECK(parseNoteEntry(" ") == std::nullopt);
|
||||||
|
CHECK(parseNoteEntry("hello") == std::nullopt);
|
||||||
|
CHECK(parseNoteEntry("C") == std::nullopt); // a bare letter with no octave is ambiguous
|
||||||
|
CHECK(parseNoteEntry("H4") == std::nullopt); // H is not a note letter
|
||||||
|
CHECK(parseNoteEntry("+") == std::nullopt);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testPlainIntegers();
|
||||||
|
testIntegerClamps();
|
||||||
|
testNoteNames();
|
||||||
|
testAccidentals();
|
||||||
|
testNoteNameClamps();
|
||||||
|
testRejects();
|
||||||
|
|
||||||
|
if (g_fail == 0) std::printf("note_entry: all tests passed\n");
|
||||||
|
return g_fail != 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// Standalone tests for reasampler::vst::param_slider — no VST3, no REAPER, no framework.
|
||||||
|
// Same fast assert loop as the sibling pure editor tests (capture_browser / keyboard_strip):
|
||||||
|
// assert the S12/S15/S16 control-surface layout, toggle-segment split + hit-test, slider
|
||||||
|
// value<->pixel mapping (round-trip + clamping + endpoints), and point->control routing.
|
||||||
|
//
|
||||||
|
// Covers: layoutControls stacking rows top-down with the label column + control column and the
|
||||||
|
// inter-row gap; an empty list / degenerate panel yielding nothing; toggleSegmentRect splitting
|
||||||
|
// a toggle into two tiling segments (last absorbs the remainder) + toggleSegmentHitTest;
|
||||||
|
// sliderTrackRect insetting a half-handle at each end; sliderHandleRect at value 0/0.5/1 and
|
||||||
|
// out-of-range clamping; valueAtPoint mapping x back to 0..1 (endpoints saturate) as the inverse
|
||||||
|
// of the handle position; controlAtPoint routing a point to the right control id (toggle whole
|
||||||
|
// area vs slider track) and MISSING in the label column, a row gap, and off-panel.
|
||||||
|
|
||||||
|
#include "../src/vst/param_slider.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace reasampler::vst;
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
|
static bool approx(double a, double b) { return (a - b) < 1e-9 && (b - a) < 1e-9; }
|
||||||
|
|
||||||
|
// --- layoutControls -----------------------------------------------------------
|
||||||
|
|
||||||
|
static void testLayoutStacksRows() {
|
||||||
|
const Rect panel{0, 100, 300, 400};
|
||||||
|
std::vector<ControlDesc> ctl{
|
||||||
|
{1, ControlKind::Toggle},
|
||||||
|
{2, ControlKind::Slider},
|
||||||
|
{3, ControlKind::Slider},
|
||||||
|
};
|
||||||
|
const std::vector<ControlRow> rows = layoutControls(panel, ctl);
|
||||||
|
CHECK(rows.size() == 3);
|
||||||
|
// Row 0 sits at the panel top; each subsequent row is one row-height + gap below.
|
||||||
|
CHECK(rows[0].row.top == 100);
|
||||||
|
CHECK(rows[0].row.bottom == 100 + kControlRowHeight);
|
||||||
|
CHECK(rows[1].row.top == rows[0].row.bottom + kControlRowGap);
|
||||||
|
CHECK(rows[2].row.top == rows[1].row.bottom + kControlRowGap);
|
||||||
|
// Ids + kinds carried through in order.
|
||||||
|
CHECK(rows[0].id == 1 && rows[0].kind == ControlKind::Toggle);
|
||||||
|
CHECK(rows[1].id == 2 && rows[1].kind == ControlKind::Slider);
|
||||||
|
// Label column then control column, contiguous, spanning the panel width.
|
||||||
|
CHECK(rows[0].label.left == panel.left);
|
||||||
|
CHECK(rows[0].control.left == rows[0].label.right);
|
||||||
|
CHECK(rows[0].control.right == panel.right);
|
||||||
|
CHECK(rows[0].label.width() == kControlLabelWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testLayoutEmptyAndDegenerate() {
|
||||||
|
CHECK(layoutControls(Rect{0, 0, 300, 300}, {}).empty());
|
||||||
|
std::vector<ControlDesc> ctl{{1, ControlKind::Slider}};
|
||||||
|
CHECK(layoutControls(Rect{0, 0, 0, 0}, ctl).empty());
|
||||||
|
CHECK(layoutControls(Rect{0, 0, 300, 0}, ctl).empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testLayoutNarrowPanelClampsLabel() {
|
||||||
|
// A panel narrower than 2*labelWidth clamps the label column to half so a control column
|
||||||
|
// survives.
|
||||||
|
const Rect panel{0, 0, 100, 200};
|
||||||
|
const std::vector<ControlRow> rows = layoutControls(panel, {{1, ControlKind::Slider}});
|
||||||
|
CHECK(rows.size() == 1);
|
||||||
|
CHECK(rows[0].label.width() <= panel.width() / 2 + 1);
|
||||||
|
CHECK(rows[0].control.width() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- toggle -------------------------------------------------------------------
|
||||||
|
|
||||||
|
static void testToggleSegmentsTile() {
|
||||||
|
const Rect control{100, 0, 300, 22}; // width 200
|
||||||
|
const Rect s0 = toggleSegmentRect(control, 0);
|
||||||
|
const Rect s1 = toggleSegmentRect(control, 1);
|
||||||
|
CHECK(s0.left == 100 && s0.right == 200);
|
||||||
|
CHECK(s1.left == 200 && s1.right == 300); // last absorbs remainder -> reaches control.right
|
||||||
|
// Out of range.
|
||||||
|
CHECK(toggleSegmentRect(control, 2).width() == 0);
|
||||||
|
CHECK(toggleSegmentRect(control, -1).width() == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testToggleSegmentRemainderInLast() {
|
||||||
|
const Rect control{0, 0, 201, 22}; // odd width -> seg0 = 100, seg1 = 101 (absorbs remainder)
|
||||||
|
CHECK(toggleSegmentRect(control, 0).width() == 100);
|
||||||
|
CHECK(toggleSegmentRect(control, 1).right == 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testToggleHitTest() {
|
||||||
|
const Rect control{100, 0, 300, 22};
|
||||||
|
CHECK(toggleSegmentHitTest(control, 150, 10) == 0);
|
||||||
|
CHECK(toggleSegmentHitTest(control, 250, 10) == 1);
|
||||||
|
CHECK(toggleSegmentHitTest(control, 50, 10) == -1); // left of control
|
||||||
|
CHECK(toggleSegmentHitTest(control, 150, 40) == -1); // below control
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- slider -------------------------------------------------------------------
|
||||||
|
|
||||||
|
static void testSliderTrackInsetsHalfHandle() {
|
||||||
|
const Rect control{100, 0, 300, 22};
|
||||||
|
const Rect track = sliderTrackRect(control);
|
||||||
|
CHECK(track.left == control.left + kSliderHandleWidth / 2);
|
||||||
|
CHECK(track.right == control.right - kSliderHandleWidth / 2);
|
||||||
|
// A control too narrow for a handle yields an empty track.
|
||||||
|
CHECK(sliderTrackRect(Rect{0, 0, kSliderHandleWidth - 1, 22}).width() == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testSliderHandleAtEndpointsAndMid() {
|
||||||
|
const Rect control{100, 0, 300, 22};
|
||||||
|
const Rect track = sliderTrackRect(control);
|
||||||
|
const int half = kSliderHandleWidth / 2;
|
||||||
|
// Value 0 -> handle centered at track.left.
|
||||||
|
const Rect h0 = sliderHandleRect(control, 0.0);
|
||||||
|
CHECK(h0.left + half == track.left);
|
||||||
|
// Value 1 -> handle centered at track.right.
|
||||||
|
const Rect h1 = sliderHandleRect(control, 1.0);
|
||||||
|
CHECK(h1.left + half == track.right);
|
||||||
|
// Value 0.5 -> centered at the track middle.
|
||||||
|
const Rect hm = sliderHandleRect(control, 0.5);
|
||||||
|
CHECK(hm.left + half == track.left + track.width() / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testSliderHandleClampsOutOfRange() {
|
||||||
|
const Rect control{0, 0, 200, 22};
|
||||||
|
CHECK(sliderHandleRect(control, -0.5).left == sliderHandleRect(control, 0.0).left);
|
||||||
|
CHECK(sliderHandleRect(control, 5.0).left == sliderHandleRect(control, 1.0).left);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testValueAtPointEndpointsSaturate() {
|
||||||
|
const Rect control{100, 0, 300, 22};
|
||||||
|
const Rect track = sliderTrackRect(control);
|
||||||
|
CHECK(approx(valueAtPoint(control, track.left - 20), 0.0));
|
||||||
|
CHECK(approx(valueAtPoint(control, track.left), 0.0));
|
||||||
|
CHECK(approx(valueAtPoint(control, track.right + 20), 1.0));
|
||||||
|
CHECK(approx(valueAtPoint(control, track.right), 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testValueAtPointIsHandleInverse() {
|
||||||
|
// Round-trip: a value -> handle center -> valueAtPoint recovers (within one pixel quantum).
|
||||||
|
const Rect control{50, 0, 450, 22}; // wide track for pixel resolution
|
||||||
|
const Rect track = sliderTrackRect(control);
|
||||||
|
for (double v : {0.1, 0.25, 0.5, 0.75, 0.9}) {
|
||||||
|
const Rect h = sliderHandleRect(control, v);
|
||||||
|
const int centerX = h.left + kSliderHandleWidth / 2;
|
||||||
|
const double back = valueAtPoint(control, centerX);
|
||||||
|
CHECK(back >= v - 0.01 && back <= v + 0.01);
|
||||||
|
CHECK(centerX >= track.left && centerX <= track.right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testValueAtPointDegenerateTrack() {
|
||||||
|
CHECK(approx(valueAtPoint(Rect{0, 0, kSliderHandleWidth - 1, 22}, 5), 0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- controlAtPoint routing ---------------------------------------------------
|
||||||
|
|
||||||
|
static void testControlAtPointRoutes() {
|
||||||
|
const Rect panel{0, 0, 300, 400};
|
||||||
|
std::vector<ControlDesc> ctl{
|
||||||
|
{10, ControlKind::Toggle},
|
||||||
|
{20, ControlKind::Slider},
|
||||||
|
};
|
||||||
|
const std::vector<ControlRow> rows = layoutControls(panel, ctl);
|
||||||
|
// A point in the toggle's control area routes to the toggle id.
|
||||||
|
const Rect tctl = rows[0].control;
|
||||||
|
CHECK(controlAtPoint(rows, (tctl.left + tctl.right) / 2, (tctl.top + tctl.bottom) / 2) == 10);
|
||||||
|
// A point on the slider's track routes to the slider id.
|
||||||
|
const Rect strack = sliderTrackRect(rows[1].control);
|
||||||
|
CHECK(controlAtPoint(rows, (strack.left + strack.right) / 2,
|
||||||
|
(strack.top + strack.bottom) / 2) == 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testControlAtPointMisses() {
|
||||||
|
const Rect panel{0, 0, 300, 400};
|
||||||
|
const std::vector<ControlRow> rows =
|
||||||
|
layoutControls(panel, {{10, ControlKind::Toggle}, {20, ControlKind::Slider}});
|
||||||
|
// The label column is not interactive.
|
||||||
|
CHECK(controlAtPoint(rows, rows[0].label.left + 2, rows[0].label.top + 4) == -1);
|
||||||
|
// The gap between rows is a miss.
|
||||||
|
const int gapY = rows[0].row.bottom + kControlRowGap / 2;
|
||||||
|
CHECK(controlAtPoint(rows, 200, gapY) == -1);
|
||||||
|
// Off-panel below.
|
||||||
|
CHECK(controlAtPoint(rows, 200, 5000) == -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testLayoutStacksRows();
|
||||||
|
testLayoutEmptyAndDegenerate();
|
||||||
|
testLayoutNarrowPanelClampsLabel();
|
||||||
|
testToggleSegmentsTile();
|
||||||
|
testToggleSegmentRemainderInLast();
|
||||||
|
testToggleHitTest();
|
||||||
|
testSliderTrackInsetsHalfHandle();
|
||||||
|
testSliderHandleAtEndpointsAndMid();
|
||||||
|
testSliderHandleClampsOutOfRange();
|
||||||
|
testValueAtPointEndpointsSaturate();
|
||||||
|
testValueAtPointIsHandleInverse();
|
||||||
|
testValueAtPointDegenerateTrack();
|
||||||
|
testControlAtPointRoutes();
|
||||||
|
testControlAtPointMisses();
|
||||||
|
|
||||||
|
if (g_fail == 0) std::printf("param_slider: all tests passed\n");
|
||||||
|
return g_fail != 0;
|
||||||
|
}
|
||||||
+325
-59
@@ -262,12 +262,6 @@ static void testBuildKeymapSingleFullZone() {
|
|||||||
CHECK(km.resolve(127, 100).matched);
|
CHECK(km.resolve(127, 100).matched);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testBuildKeymapRateDefault() {
|
|
||||||
// A zero/invalid rate defaults to 44100 rather than producing a divide-by-zero-shaped
|
|
||||||
// sample rate downstream.
|
|
||||||
const Keymap km = buildTier0Keymap({0.1f}, 0, 60, SampleLoop{});
|
|
||||||
CHECK(km.samples.size() == 1 && km.samples[0].sampleRate == 44100);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- selection state (setState/getState) --------------------------------------
|
// --- selection state (setState/getState) --------------------------------------
|
||||||
|
|
||||||
@@ -279,6 +273,7 @@ static void testSelectionStateRoundTrip() {
|
|||||||
CHECK(deserializeSelection(bytes) == id);
|
CHECK(deserializeSelection(bytes) == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static void testSelectionStateEmptyId() {
|
static void testSelectionStateEmptyId() {
|
||||||
const std::vector<std::uint8_t> bytes = serializeSelection("");
|
const std::vector<std::uint8_t> bytes = serializeSelection("");
|
||||||
CHECK(bytes.size() == 4); // just the version tag
|
CHECK(bytes.size() == 4); // just the version tag
|
||||||
@@ -603,7 +598,7 @@ static void testPerformanceStateRoundTrip() {
|
|||||||
m.zones.push_back(zone("kick", 36, 47)); // no override
|
m.zones.push_back(zone("kick", 36, 47)); // no override
|
||||||
m.zones.push_back(zone("snare", 48, 59, /*override=*/50)); // with override
|
m.zones.push_back(zone("snare", 48, 59, /*override=*/50)); // with override
|
||||||
const std::vector<std::uint8_t> bytes = serializePerformance(m);
|
const std::vector<std::uint8_t> bytes = serializePerformance(m);
|
||||||
const PerformanceMap back = deserializePerformance(bytes);
|
const PerformanceMap back = deserializePerformance(bytes, 44100.0);
|
||||||
CHECK(back.zones.size() == 2);
|
CHECK(back.zones.size() == 2);
|
||||||
CHECK(back.zones.size() == 2 && back.zones[0].sampleId == "kick");
|
CHECK(back.zones.size() == 2 && back.zones[0].sampleId == "kick");
|
||||||
CHECK(back.zones.size() == 2 && back.zones[0].lowNote == 36 && back.zones[0].highNote == 47);
|
CHECK(back.zones.size() == 2 && back.zones[0].lowNote == 36 && back.zones[0].highNote == 47);
|
||||||
@@ -617,7 +612,7 @@ static void testPerformanceStateEmpty() {
|
|||||||
const std::vector<std::uint8_t> bytes = serializePerformance(PerformanceMap{});
|
const std::vector<std::uint8_t> bytes = serializePerformance(PerformanceMap{});
|
||||||
// Envelope version (4) + zones-payload marker (4) + payload version (4) + zero count (4).
|
// Envelope version (4) + zones-payload marker (4) + payload version (4) + zero count (4).
|
||||||
CHECK(bytes.size() == 16);
|
CHECK(bytes.size() == 16);
|
||||||
CHECK(deserializePerformance(bytes).zones.empty());
|
CHECK(deserializePerformance(bytes, 44100.0).zones.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testPerformanceStateLoopStartRoundTrip() {
|
static void testPerformanceStateLoopStartRoundTrip() {
|
||||||
@@ -630,7 +625,7 @@ static void testPerformanceStateLoopStartRoundTrip() {
|
|||||||
m.zones.push_back(z);
|
m.zones.push_back(z);
|
||||||
// A second zone with NO overrides proves the optional tail is per-record.
|
// A second zone with NO overrides proves the optional tail is per-record.
|
||||||
m.zones.push_back(zone("kick", 0, 23));
|
m.zones.push_back(zone("kick", 0, 23));
|
||||||
const PerformanceMap back = deserializePerformance(serializePerformance(m));
|
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
|
||||||
CHECK(back.zones.size() == 2);
|
CHECK(back.zones.size() == 2);
|
||||||
CHECK(back.zones.size() == 2 && back.zones[0].rootOverride.has_value() &&
|
CHECK(back.zones.size() == 2 && back.zones[0].rootOverride.has_value() &&
|
||||||
*back.zones[0].rootOverride == 64);
|
*back.zones[0].rootOverride == 64);
|
||||||
@@ -662,7 +657,7 @@ static void testPerformanceStateV1PayloadBackCompat() {
|
|||||||
u32(10); // lowNote
|
u32(10); // lowNote
|
||||||
u32(40); // highNote
|
u32(40); // highNote
|
||||||
b.push_back(0); // hasRootOverride = 0 (record ends here in v1)
|
b.push_back(0); // hasRootOverride = 0 (record ends here in v1)
|
||||||
const PerformanceMap back = deserializePerformance(b);
|
const PerformanceMap back = deserializePerformance(b, 44100.0);
|
||||||
CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy");
|
CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy");
|
||||||
CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 10 && back.zones[0].highNote == 40);
|
CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 10 && back.zones[0].highNote == 40);
|
||||||
CHECK(back.zones.size() == 1 && !back.zones[0].loopOverride.has_value());
|
CHECK(back.zones.size() == 1 && !back.zones[0].loopOverride.has_value());
|
||||||
@@ -679,7 +674,7 @@ static void testComponentStateLoopStartRoundTrip() {
|
|||||||
z.loopOverride = lp;
|
z.loopOverride = lp;
|
||||||
z.startPoint = 128;
|
z.startPoint = 128;
|
||||||
s.map.zones.push_back(z);
|
s.map.zones.push_back(z);
|
||||||
const ComponentState back = deserializeComponentState(serializeComponentState(s));
|
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||||
CHECK(back.selectionId == "pick");
|
CHECK(back.selectionId == "pick");
|
||||||
CHECK(back.map.zones.size() == 1 && back.map.zones[0].loopOverride.has_value() &&
|
CHECK(back.map.zones.size() == 1 && back.map.zones[0].loopOverride.has_value() &&
|
||||||
back.map.zones[0].loopOverride->start == 500 &&
|
back.map.zones[0].loopOverride->start == 500 &&
|
||||||
@@ -691,25 +686,25 @@ static void testComponentStateLoopStartRoundTrip() {
|
|||||||
static void testPerformanceStateV1BackCompat() {
|
static void testPerformanceStateV1BackCompat() {
|
||||||
// A v1 blob (the S4 single-selection format) lifts to a single full-keyboard zone.
|
// A v1 blob (the S4 single-selection format) lifts to a single full-keyboard zone.
|
||||||
const std::vector<std::uint8_t> v1 = serializeSelection("legacy-sample-id");
|
const std::vector<std::uint8_t> v1 = serializeSelection("legacy-sample-id");
|
||||||
const PerformanceMap back = deserializePerformance(v1);
|
const PerformanceMap back = deserializePerformance(v1, 44100.0);
|
||||||
CHECK(back.zones.size() == 1);
|
CHECK(back.zones.size() == 1);
|
||||||
CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy-sample-id");
|
CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy-sample-id");
|
||||||
CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 0 && back.zones[0].highNote == 127);
|
CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 0 && back.zones[0].highNote == 127);
|
||||||
CHECK(back.zones.size() == 1 && !back.zones[0].rootOverride.has_value());
|
CHECK(back.zones.size() == 1 && !back.zones[0].rootOverride.has_value());
|
||||||
// A v1 blob with an EMPTY id lifts to an empty map (no zone for "no selection").
|
// A v1 blob with an EMPTY id lifts to an empty map (no zone for "no selection").
|
||||||
CHECK(deserializePerformance(serializeSelection("")).zones.empty());
|
CHECK(deserializePerformance(serializeSelection(""), 44100.0).zones.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testPerformanceStateGarbage() {
|
static void testPerformanceStateGarbage() {
|
||||||
// Unknown version / truncated / empty -> empty map (never throws).
|
// Unknown version / truncated / empty -> empty map (never throws).
|
||||||
CHECK(deserializePerformance({}).zones.empty());
|
CHECK(deserializePerformance({}, 44100.0).zones.empty());
|
||||||
CHECK(deserializePerformance({0xAA, 0xBB, 0xCC, 0xDD}).zones.empty()); // unknown version
|
CHECK(deserializePerformance({0xAA, 0xBB, 0xCC, 0xDD}, 44100.0).zones.empty()); // unknown version
|
||||||
// Truncated mid-zone: valid v2 header claiming 1 zone but no zone bytes -> empty.
|
// Truncated mid-zone: valid v2 header claiming 1 zone but no zone bytes -> empty.
|
||||||
std::vector<std::uint8_t> t;
|
std::vector<std::uint8_t> t;
|
||||||
t.push_back(2); t.push_back(0); t.push_back(0); t.push_back(0); // version 2
|
t.push_back(2); t.push_back(0); t.push_back(0); t.push_back(0); // version 2
|
||||||
t.push_back(1); t.push_back(0); t.push_back(0); t.push_back(0); // count 1
|
t.push_back(1); t.push_back(0); t.push_back(0); t.push_back(0); // count 1
|
||||||
// (no zone payload)
|
// (no zone payload)
|
||||||
CHECK(deserializePerformance(t).zones.empty());
|
CHECK(deserializePerformance(t, 44100.0).zones.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testPerformanceStateNegativeNotesRoundTrip() {
|
static void testPerformanceStateNegativeNotesRoundTrip() {
|
||||||
@@ -717,7 +712,7 @@ static void testPerformanceStateNegativeNotesRoundTrip() {
|
|||||||
// a hand-set/legacy value round-trips without corruption (two's-complement on the wire).
|
// a hand-set/legacy value round-trips without corruption (two's-complement on the wire).
|
||||||
PerformanceMap m;
|
PerformanceMap m;
|
||||||
m.zones.push_back(zone("s", 0, 127, /*override=*/0));
|
m.zones.push_back(zone("s", 0, 127, /*override=*/0));
|
||||||
const PerformanceMap back = deserializePerformance(serializePerformance(m));
|
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
|
||||||
CHECK(back.zones.size() == 1 && back.zones[0].rootOverride.has_value() &&
|
CHECK(back.zones.size() == 1 && back.zones[0].rootOverride.has_value() &&
|
||||||
*back.zones[0].rootOverride == 0);
|
*back.zones[0].rootOverride == 0);
|
||||||
}
|
}
|
||||||
@@ -730,7 +725,7 @@ static void testComponentStateRoundTrip() {
|
|||||||
s.selectionId = "picked-capture";
|
s.selectionId = "picked-capture";
|
||||||
s.map.zones.push_back(zone("z0", 0, 59, /*override=*/std::nullopt));
|
s.map.zones.push_back(zone("z0", 0, 59, /*override=*/std::nullopt));
|
||||||
s.map.zones.push_back(zone("z1", 60, 127, /*override=*/48));
|
s.map.zones.push_back(zone("z1", 60, 127, /*override=*/48));
|
||||||
const ComponentState back = deserializeComponentState(serializeComponentState(s));
|
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||||
CHECK(back.selectionId == "picked-capture");
|
CHECK(back.selectionId == "picked-capture");
|
||||||
CHECK(back.map.zones.size() == 2);
|
CHECK(back.map.zones.size() == 2);
|
||||||
CHECK(back.map.zones.size() == 2 && back.map.zones[0].sampleId == "z0" &&
|
CHECK(back.map.zones.size() == 2 && back.map.zones[0].sampleId == "z0" &&
|
||||||
@@ -744,7 +739,7 @@ static void testComponentStateSelectionOnlyNoZones() {
|
|||||||
// (NOT synthesize a zone) — the default face is one capture, zones are opt-in.
|
// (NOT synthesize a zone) — the default face is one capture, zones are opt-in.
|
||||||
ComponentState s;
|
ComponentState s;
|
||||||
s.selectionId = "just-a-pick";
|
s.selectionId = "just-a-pick";
|
||||||
const ComponentState back = deserializeComponentState(serializeComponentState(s));
|
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||||
CHECK(back.selectionId == "just-a-pick");
|
CHECK(back.selectionId == "just-a-pick");
|
||||||
CHECK(back.map.zones.empty());
|
CHECK(back.map.zones.empty());
|
||||||
}
|
}
|
||||||
@@ -752,7 +747,7 @@ static void testComponentStateSelectionOnlyNoZones() {
|
|||||||
static void testComponentStateEmptyIsEmpty() {
|
static void testComponentStateEmptyIsEmpty() {
|
||||||
// No pick, no zones -> restores EMPTY (the S10 silent empty state), never a first sample.
|
// No pick, no zones -> restores EMPTY (the S10 silent empty state), never a first sample.
|
||||||
const ComponentState s; // selectionId "", empty map
|
const ComponentState s; // selectionId "", empty map
|
||||||
const ComponentState back = deserializeComponentState(serializeComponentState(s));
|
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||||
CHECK(back.selectionId.empty());
|
CHECK(back.selectionId.empty());
|
||||||
CHECK(back.map.zones.empty());
|
CHECK(back.map.zones.empty());
|
||||||
}
|
}
|
||||||
@@ -761,13 +756,13 @@ static void testComponentStateV1BackCompat() {
|
|||||||
// A v1 S4 blob (single-selection) lifts to {id, one full-keyboard zone} so an old pick
|
// 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.
|
// survives as BOTH the selection and a one-zone map.
|
||||||
const std::vector<std::uint8_t> v1 = serializeSelection("legacy-id");
|
const std::vector<std::uint8_t> v1 = serializeSelection("legacy-id");
|
||||||
const ComponentState back = deserializeComponentState(v1);
|
const ComponentState back = deserializeComponentState(v1, 44100.0);
|
||||||
CHECK(back.selectionId == "legacy-id");
|
CHECK(back.selectionId == "legacy-id");
|
||||||
CHECK(back.map.zones.size() == 1);
|
CHECK(back.map.zones.size() == 1);
|
||||||
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "legacy-id" &&
|
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);
|
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).
|
// A v1 blob with an EMPTY id -> empty state (no selection, no zone).
|
||||||
const ComponentState empty = deserializeComponentState(serializeSelection(""));
|
const ComponentState empty = deserializeComponentState(serializeSelection(""), 44100.0);
|
||||||
CHECK(empty.selectionId.empty() && empty.map.zones.empty());
|
CHECK(empty.selectionId.empty() && empty.map.zones.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,7 +772,7 @@ static void testComponentStateV2BackCompat() {
|
|||||||
PerformanceMap m;
|
PerformanceMap m;
|
||||||
m.zones.push_back(zone("s", 12, 24, /*override=*/std::nullopt));
|
m.zones.push_back(zone("s", 12, 24, /*override=*/std::nullopt));
|
||||||
const std::vector<std::uint8_t> v2 = serializePerformance(m);
|
const std::vector<std::uint8_t> v2 = serializePerformance(m);
|
||||||
const ComponentState back = deserializeComponentState(v2);
|
const ComponentState back = deserializeComponentState(v2, 44100.0);
|
||||||
CHECK(back.selectionId.empty());
|
CHECK(back.selectionId.empty());
|
||||||
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "s" &&
|
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "s" &&
|
||||||
back.map.zones[0].lowNote == 12 && back.map.zones[0].highNote == 24);
|
back.map.zones[0].lowNote == 12 && back.map.zones[0].highNote == 24);
|
||||||
@@ -785,17 +780,17 @@ static void testComponentStateV2BackCompat() {
|
|||||||
|
|
||||||
static void testComponentStateGarbage() {
|
static void testComponentStateGarbage() {
|
||||||
// Empty / unknown version -> empty (never throws across the host).
|
// Empty / unknown version -> empty (never throws across the host).
|
||||||
CHECK(deserializeComponentState({}).selectionId.empty());
|
CHECK(deserializeComponentState({}, 44100.0).selectionId.empty());
|
||||||
CHECK(deserializeComponentState({}).map.zones.empty());
|
CHECK(deserializeComponentState({}, 44100.0).map.zones.empty());
|
||||||
const std::vector<std::uint8_t> unknown{0xAA, 0xBB, 0xCC, 0xDD};
|
const std::vector<std::uint8_t> unknown{0xAA, 0xBB, 0xCC, 0xDD};
|
||||||
CHECK(deserializeComponentState(unknown).map.zones.empty());
|
CHECK(deserializeComponentState(unknown, 44100.0).map.zones.empty());
|
||||||
CHECK(deserializeComponentState(unknown).selectionId.empty());
|
CHECK(deserializeComponentState(unknown, 44100.0).selectionId.empty());
|
||||||
// A v3 header claiming a longer id than the blob holds -> empty (bounded read).
|
// A v3 header claiming a longer id than the blob holds -> empty (bounded read).
|
||||||
std::vector<std::uint8_t> t;
|
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(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)
|
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, 44100.0).selectionId.empty());
|
||||||
CHECK(deserializeComponentState(t).map.zones.empty());
|
CHECK(deserializeComponentState(t, 44100.0).map.zones.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- S7: extractChannel / decodeChannels (cross-mode channel policy) ----------
|
// --- S7: extractChannel / decodeChannels (cross-mode channel policy) ----------
|
||||||
@@ -887,7 +882,7 @@ static void testComponentStateV4RoundTripStereo() {
|
|||||||
s.selectionId = "pick";
|
s.selectionId = "pick";
|
||||||
s.channelMode = ChannelMode::Stereo;
|
s.channelMode = ChannelMode::Stereo;
|
||||||
s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
|
s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
|
||||||
const ComponentState back = deserializeComponentState(serializeComponentState(s));
|
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||||
CHECK(back.selectionId == "pick");
|
CHECK(back.selectionId == "pick");
|
||||||
CHECK(back.channelMode == ChannelMode::Stereo); // mode round-trips
|
CHECK(back.channelMode == ChannelMode::Stereo); // mode round-trips
|
||||||
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0");
|
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0");
|
||||||
@@ -897,14 +892,14 @@ static void testComponentStateV4RoundTripMono() {
|
|||||||
ComponentState s;
|
ComponentState s;
|
||||||
s.selectionId = "pick";
|
s.selectionId = "pick";
|
||||||
s.channelMode = ChannelMode::Mono;
|
s.channelMode = ChannelMode::Mono;
|
||||||
const ComponentState back = deserializeComponentState(serializeComponentState(s));
|
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||||
CHECK(back.selectionId == "pick");
|
CHECK(back.selectionId == "pick");
|
||||||
CHECK(back.channelMode == ChannelMode::Mono);
|
CHECK(back.channelMode == ChannelMode::Mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testComponentStateV4DefaultIsMono() {
|
static void testComponentStateV4DefaultIsMono() {
|
||||||
// A default-constructed state serializes with mono and restores mono (preserves behavior).
|
// A default-constructed state serializes with mono and restores mono (preserves behavior).
|
||||||
const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}));
|
const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0);
|
||||||
CHECK(back.channelMode == ChannelMode::Mono);
|
CHECK(back.channelMode == ChannelMode::Mono);
|
||||||
CHECK(back.selectionId.empty() && back.map.zones.empty());
|
CHECK(back.selectionId.empty() && back.map.zones.empty());
|
||||||
}
|
}
|
||||||
@@ -918,7 +913,7 @@ static void testComponentStateV3LiftsToMono() {
|
|||||||
v3.push_back(static_cast<std::uint8_t>(id.size())); v3.push_back(0); v3.push_back(0); v3.push_back(0);
|
v3.push_back(static_cast<std::uint8_t>(id.size())); v3.push_back(0); v3.push_back(0); v3.push_back(0);
|
||||||
v3.insert(v3.end(), id.begin(), id.end());
|
v3.insert(v3.end(), id.begin(), id.end());
|
||||||
v3.push_back(0); v3.push_back(0); v3.push_back(0); v3.push_back(0); // zone count 0
|
v3.push_back(0); v3.push_back(0); v3.push_back(0); v3.push_back(0); // zone count 0
|
||||||
const ComponentState back = deserializeComponentState(v3);
|
const ComponentState back = deserializeComponentState(v3, 44100.0);
|
||||||
CHECK(back.selectionId == "legacy");
|
CHECK(back.selectionId == "legacy");
|
||||||
CHECK(back.channelMode == ChannelMode::Mono); // pre-S7 default
|
CHECK(back.channelMode == ChannelMode::Mono); // pre-S7 default
|
||||||
CHECK(back.map.zones.empty());
|
CHECK(back.map.zones.empty());
|
||||||
@@ -926,17 +921,17 @@ static void testComponentStateV3LiftsToMono() {
|
|||||||
|
|
||||||
static void testComponentStateV1V2LiftToMono() {
|
static void testComponentStateV1V2LiftToMono() {
|
||||||
// The older lifts (v1 single-selection, v2 zones-only) also default to mono under v4 read.
|
// The older lifts (v1 single-selection, v2 zones-only) also default to mono under v4 read.
|
||||||
const ComponentState v1 = deserializeComponentState(serializeSelection("old"));
|
const ComponentState v1 = deserializeComponentState(serializeSelection("old"), 44100.0);
|
||||||
CHECK(v1.channelMode == ChannelMode::Mono && v1.selectionId == "old");
|
CHECK(v1.channelMode == ChannelMode::Mono && v1.selectionId == "old");
|
||||||
PerformanceMap m; m.zones.push_back(zone("s", 12, 24));
|
PerformanceMap m; m.zones.push_back(zone("s", 12, 24));
|
||||||
const ComponentState v2 = deserializeComponentState(serializePerformance(m));
|
const ComponentState v2 = deserializeComponentState(serializePerformance(m), 44100.0);
|
||||||
CHECK(v2.channelMode == ChannelMode::Mono && v2.map.zones.size() == 1);
|
CHECK(v2.channelMode == ChannelMode::Mono && v2.map.zones.size() == 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testComponentStateV4TruncatedModeByte() {
|
static void testComponentStateV4TruncatedModeByte() {
|
||||||
// A v4 blob truncated right after the version tag (no mode byte) -> empty, mono default holds.
|
// A v4 blob truncated right after the version tag (no mode byte) -> empty, mono default holds.
|
||||||
std::vector<std::uint8_t> t{4, 0, 0, 0}; // version 4, nothing after
|
std::vector<std::uint8_t> t{4, 0, 0, 0}; // version 4, nothing after
|
||||||
const ComponentState back = deserializeComponentState(t);
|
const ComponentState back = deserializeComponentState(t, 44100.0);
|
||||||
CHECK(back.channelMode == ChannelMode::Mono);
|
CHECK(back.channelMode == ChannelMode::Mono);
|
||||||
CHECK(back.selectionId.empty() && back.map.zones.empty());
|
CHECK(back.selectionId.empty() && back.map.zones.empty());
|
||||||
}
|
}
|
||||||
@@ -959,7 +954,7 @@ static void testComponentStateV4StereoWithZoneOverridesRoundTrip() {
|
|||||||
s.map.zones.push_back(z0);
|
s.map.zones.push_back(z0);
|
||||||
s.map.zones.push_back(z1);
|
s.map.zones.push_back(z1);
|
||||||
|
|
||||||
const ComponentState back = deserializeComponentState(serializeComponentState(s));
|
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||||
CHECK(back.channelMode == ChannelMode::Stereo); // envelope field survives
|
CHECK(back.channelMode == ChannelMode::Stereo); // envelope field survives
|
||||||
CHECK(back.selectionId == "pick");
|
CHECK(back.selectionId == "pick");
|
||||||
CHECK(back.map.zones.size() == 2);
|
CHECK(back.map.zones.size() == 2);
|
||||||
@@ -989,7 +984,7 @@ static void testComponentStateV5MarkerRoundTrip() {
|
|||||||
s.channelMode = ChannelMode::Stereo;
|
s.channelMode = ChannelMode::Stereo;
|
||||||
s.lastConsumedAssignGeneration = 1700000123456LL; // > INT32_MAX
|
s.lastConsumedAssignGeneration = 1700000123456LL; // > INT32_MAX
|
||||||
s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
|
s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
|
||||||
const ComponentState back = deserializeComponentState(serializeComponentState(s));
|
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||||
CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // marker survives
|
CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // marker survives
|
||||||
CHECK(back.channelMode == ChannelMode::Stereo);
|
CHECK(back.channelMode == ChannelMode::Stereo);
|
||||||
CHECK(back.selectionId == "pick");
|
CHECK(back.selectionId == "pick");
|
||||||
@@ -999,7 +994,7 @@ static void testComponentStateV5MarkerRoundTrip() {
|
|||||||
static void testComponentStateDefaultMarkerIsZero() {
|
static void testComponentStateDefaultMarkerIsZero() {
|
||||||
// A default-constructed state has marker 0 and round-trips 0 — a fresh instance's first
|
// A default-constructed state has marker 0 and round-trips 0 — a fresh instance's first
|
||||||
// assign (generation >= 1) must not be swallowed by a non-zero default.
|
// assign (generation >= 1) must not be swallowed by a non-zero default.
|
||||||
const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}));
|
const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0);
|
||||||
CHECK(back.lastConsumedAssignGeneration == 0);
|
CHECK(back.lastConsumedAssignGeneration == 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1015,7 +1010,7 @@ static void testComponentStateV4LiftsMarkerToZero() {
|
|||||||
v4.push_back(static_cast<std::uint8_t>(id.size())); v4.push_back(0); v4.push_back(0); v4.push_back(0);
|
v4.push_back(static_cast<std::uint8_t>(id.size())); v4.push_back(0); v4.push_back(0); v4.push_back(0);
|
||||||
v4.insert(v4.end(), id.begin(), id.end());
|
v4.insert(v4.end(), id.begin(), id.end());
|
||||||
v4.push_back(0); v4.push_back(0); v4.push_back(0); v4.push_back(0); // zone count 0
|
v4.push_back(0); v4.push_back(0); v4.push_back(0); v4.push_back(0); // zone count 0
|
||||||
const ComponentState back = deserializeComponentState(v4);
|
const ComponentState back = deserializeComponentState(v4, 44100.0);
|
||||||
CHECK(back.lastConsumedAssignGeneration == 0); // no marker in v4 -> default 0
|
CHECK(back.lastConsumedAssignGeneration == 0); // no marker in v4 -> default 0
|
||||||
CHECK(back.channelMode == ChannelMode::Stereo); // v4 mode byte still honored
|
CHECK(back.channelMode == ChannelMode::Stereo); // v4 mode byte still honored
|
||||||
CHECK(back.selectionId == "saved");
|
CHECK(back.selectionId == "saved");
|
||||||
@@ -1026,42 +1021,162 @@ static void testComponentStateV5TruncatedMarker() {
|
|||||||
// A v5 blob truncated inside the 8-byte marker (mode byte present, marker cut short) -> empty,
|
// A v5 blob truncated inside the 8-byte marker (mode byte present, marker cut short) -> empty,
|
||||||
// mono + marker 0 default holds (bounded read, never throws across the host).
|
// mono + marker 0 default holds (bounded read, never throws across the host).
|
||||||
std::vector<std::uint8_t> t{5, 0, 0, 0, 1, 0xAA, 0xBB}; // version 5, mode byte, 2 marker bytes
|
std::vector<std::uint8_t> t{5, 0, 0, 0, 1, 0xAA, 0xBB}; // version 5, mode byte, 2 marker bytes
|
||||||
const ComponentState back = deserializeComponentState(t);
|
const ComponentState back = deserializeComponentState(t, 44100.0);
|
||||||
CHECK(back.lastConsumedAssignGeneration == 0);
|
CHECK(back.lastConsumedAssignGeneration == 0);
|
||||||
CHECK(back.selectionId.empty() && back.map.zones.empty());
|
CHECK(back.selectionId.empty() && back.map.zones.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- MERGE COMPOSITION (S9 v5 marker envelope x S15/S16 v3 play-param payload) ----------------
|
||||||
|
//
|
||||||
|
// The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and
|
||||||
|
// ps-w9-t2-modes (payload v3, adds the per-zone play params) makes THREE combinations first
|
||||||
|
// reachable. Each pre-existing suite covers one axis in isolation; these lock the axes together.
|
||||||
|
|
||||||
|
static void testV5EnvelopeWithMarkerAndPlayParamsRoundTrip() {
|
||||||
|
// (a) The full v5 face: channelMode + the S8/S9 consumed marker (envelope) AND zones carrying
|
||||||
|
// S15/S16 play params (payload) must ALL survive one serialize/deserialize. The two extensions
|
||||||
|
// sit on orthogonal tracks (envelope vs self-versioned payload); this proves they compose with
|
||||||
|
// no field cross-talk — neither the marker read nor the play-param read consumes the other's bytes.
|
||||||
|
ComponentState s;
|
||||||
|
s.selectionId = "pick";
|
||||||
|
s.channelMode = ChannelMode::Stereo;
|
||||||
|
s.lastConsumedAssignGeneration = 1700000123456LL; // > INT32_MAX -> exercises the full 8-byte field
|
||||||
|
PerformanceZone z = zone("z0", 0, 127, /*override=*/48);
|
||||||
|
z.play.playMode = PlayMode::Trigger;
|
||||||
|
z.play.adsr.holdSeconds = 0.093;
|
||||||
|
z.play.trigger.lengthFraction = 0.625;
|
||||||
|
z.play.trigger.fadeInFrames = 32;
|
||||||
|
z.play.trigger.fadeOutFrames = 96;
|
||||||
|
z.play.pitchEngine = PitchEngine::Varispeed;
|
||||||
|
z.play.pitchEnv.enabled = true;
|
||||||
|
z.play.pitchEnv.attackSeconds = 0.00018;
|
||||||
|
z.play.pitchEnv.decaySeconds = 0.0145;
|
||||||
|
z.play.pitchEnv.peakSemitones = 12.5;
|
||||||
|
s.map.zones.push_back(z);
|
||||||
|
|
||||||
|
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||||
|
CHECK(back.channelMode == ChannelMode::Stereo); // envelope: mode
|
||||||
|
CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // envelope: marker
|
||||||
|
CHECK(back.selectionId == "pick");
|
||||||
|
CHECK(back.map.zones.size() == 1);
|
||||||
|
if (back.map.zones.size() != 1) return;
|
||||||
|
const ZonePlaySeconds& p = back.map.zones[0].play; // payload: play params
|
||||||
|
CHECK(p.playMode == PlayMode::Trigger);
|
||||||
|
CHECK(p.adsr.holdSeconds == 0.093);
|
||||||
|
CHECK(p.trigger.lengthFraction == 0.625);
|
||||||
|
CHECK(p.trigger.fadeInFrames == 32 && p.trigger.fadeOutFrames == 96);
|
||||||
|
CHECK(p.pitchEngine == PitchEngine::Varispeed);
|
||||||
|
CHECK(p.pitchEnv.enabled && p.pitchEnv.attackSeconds == 0.00018 &&
|
||||||
|
p.pitchEnv.decaySeconds == 0.0145 && p.pitchEnv.peakSemitones == 12.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand-build ONE v3 zone record (marker-versioned payload body) for a single-zone map. Emits the
|
||||||
|
// exact on-wire order the header's PAYLOAD v3 spec + putZonesPayload write: id, lo/hi, no root/loop/
|
||||||
|
// start overrides, then the always-present S15/S16 play tail. Used to synthesize the two v4 blobs
|
||||||
|
// below WITHOUT serializeComponentState (which now emits v5) — so the reader's widened accept-chain
|
||||||
|
// is exercised against a genuine, older-envelope byte layout rather than a self-produced buffer.
|
||||||
|
static std::vector<std::uint8_t> handBuildV3PayloadOneZone(const std::string& id) {
|
||||||
|
std::vector<std::uint8_t> b;
|
||||||
|
auto u32 = [&](std::uint32_t v) {
|
||||||
|
b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF);
|
||||||
|
b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF);
|
||||||
|
};
|
||||||
|
auto u64 = [&](std::uint64_t v) {
|
||||||
|
for (int i = 0; i < 8; ++i) b.push_back(static_cast<std::uint8_t>((v >> (i * 8)) & 0xFF));
|
||||||
|
};
|
||||||
|
auto dbl = [&](double d) { std::uint64_t bits; std::memcpy(&bits, &d, 8); u64(bits); };
|
||||||
|
u32(kZonesFormatMarker);
|
||||||
|
u32(3); // PAYLOAD VERSION 3 (S15/S16 play tail present)
|
||||||
|
u32(1); // zone count 1
|
||||||
|
u32(static_cast<std::uint32_t>(id.size()));
|
||||||
|
b.insert(b.end(), id.begin(), id.end());
|
||||||
|
u32(10); // lowNote
|
||||||
|
u32(70); // highNote
|
||||||
|
b.push_back(0); // hasRootOverride = 0
|
||||||
|
b.push_back(0); // hasLoopOverride = 0
|
||||||
|
b.push_back(0); // hasStartPoint = 0
|
||||||
|
// Always-present v3 play tail: Trigger, hold, lengthFraction, fades, Varispeed, env off.
|
||||||
|
b.push_back(1); // playMode = Trigger
|
||||||
|
u64(static_cast<std::uint64_t>(2048)); // adsr.holdFrames
|
||||||
|
dbl(0.5); // trigger.lengthFraction
|
||||||
|
u64(static_cast<std::uint64_t>(16)); // trigger.fadeInFrames
|
||||||
|
u64(static_cast<std::uint64_t>(48)); // trigger.fadeOutFrames
|
||||||
|
b.push_back(0); // pitchEngine = Varispeed
|
||||||
|
b.push_back(0); // pitchEnv.enabled = 0
|
||||||
|
u64(0); // pitchEnv.attackFrames
|
||||||
|
u64(0); // pitchEnv.decayFrames
|
||||||
|
dbl(0.0); // pitchEnv.peakSemitones
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay() {
|
||||||
|
// (b) + (c) unified: a GENUINE v4 ENVELOPE blob (version tag 4: mode byte, id, then the zones
|
||||||
|
// payload — NO 8-byte marker) whose zones payload is PAYLOAD v3 (the exact shape an S15-test-build
|
||||||
|
// save produced). Under the widened accept-chain it must (b) lift lastConsumedAssignGeneration to
|
||||||
|
// 0 AND (c) deserialize its payload-v3 play params intact. This is the precise blob a user who
|
||||||
|
// saved on the S15 test build (envelope v4 + payload v3) would hold; the v4 lift branch delegates
|
||||||
|
// zones to readZonesPayload, which self-selects the v3 record shape from the payload marker — so
|
||||||
|
// the two v4 layouts (S7-era payload-v2, S15-era payload-v3) are UNAMBIGUOUS, distinguished
|
||||||
|
// inside the payload, not on the envelope.
|
||||||
|
std::vector<std::uint8_t> v4;
|
||||||
|
v4.push_back(4); v4.push_back(0); v4.push_back(0); v4.push_back(0); // ENVELOPE version 4
|
||||||
|
v4.push_back(1); // channel mode = stereo
|
||||||
|
const std::string id = "s15saved";
|
||||||
|
v4.push_back(static_cast<std::uint8_t>(id.size()));
|
||||||
|
v4.push_back(0); v4.push_back(0); v4.push_back(0); // idLen (LE)
|
||||||
|
v4.insert(v4.end(), id.begin(), id.end());
|
||||||
|
const std::vector<std::uint8_t> payload = handBuildV3PayloadOneZone("zv3");
|
||||||
|
v4.insert(v4.end(), payload.begin(), payload.end());
|
||||||
|
|
||||||
|
const ComponentState back = deserializeComponentState(v4, 44100.0);
|
||||||
|
CHECK(back.lastConsumedAssignGeneration == 0); // (b) no marker in v4 -> default 0
|
||||||
|
CHECK(back.channelMode == ChannelMode::Stereo); // v4 envelope mode honored
|
||||||
|
CHECK(back.selectionId == "s15saved");
|
||||||
|
CHECK(back.map.zones.size() == 1); // (c) payload-v3 zone parsed under widened check
|
||||||
|
if (back.map.zones.size() != 1) return;
|
||||||
|
CHECK(back.map.zones[0].sampleId == "zv3");
|
||||||
|
CHECK(back.map.zones[0].lowNote == 10 && back.map.zones[0].highNote == 70);
|
||||||
|
const ZonePlaySeconds& p = back.map.zones[0].play;
|
||||||
|
CHECK(p.playMode == PlayMode::Trigger); // (c) play params survive the v4 envelope
|
||||||
|
// Legacy v3 wall-clock frames convert to seconds at the passed project rate (44100.0 here).
|
||||||
|
CHECK(approx(p.adsr.holdSeconds, 2048.0 / 44100.0));
|
||||||
|
CHECK(p.trigger.lengthFraction == 0.5);
|
||||||
|
CHECK(p.trigger.fadeInFrames == 16 && p.trigger.fadeOutFrames == 48); // source frames, as-is
|
||||||
|
CHECK(p.pitchEngine == PitchEngine::Varispeed);
|
||||||
|
CHECK(p.pitchEnv.enabled == false);
|
||||||
|
}
|
||||||
|
|
||||||
// --- S15/S16 zone-payload v3: per-zone play params round-trip + back-compat lift -------------
|
// --- S15/S16 zone-payload v3: per-zone play params round-trip + back-compat lift -------------
|
||||||
|
|
||||||
static void testPlayParamsRoundTrip() {
|
static void testPlayParamsRoundTrip() {
|
||||||
// A zone carrying explicit S15/S16 play params (Trigger mode, hold, fades, Varispeed engine,
|
// A zone carrying explicit S15/S16 play params (Trigger mode, hold seconds, source-frame fades,
|
||||||
// pitch env on) must round-trip ALL fields losslessly through the payload-v3 tail.
|
// Varispeed engine, pitch env on) must round-trip ALL fields losslessly through the v5 tail.
|
||||||
PerformanceMap m;
|
PerformanceMap m;
|
||||||
PerformanceZone z = zone("lead", 20, 100, /*override=*/55);
|
PerformanceZone z = zone("lead", 20, 100, /*override=*/55);
|
||||||
z.play.playMode = PlayMode::Trigger;
|
z.play.playMode = PlayMode::Trigger;
|
||||||
z.play.adsr.holdFrames = 1234;
|
z.play.adsr.holdSeconds = 0.028; // wall-clock seconds
|
||||||
z.play.trigger.lengthFraction = 0.375;
|
z.play.trigger.lengthFraction = 0.375;
|
||||||
z.play.trigger.fadeInFrames = 64;
|
z.play.trigger.fadeInFrames = 64; // source frames
|
||||||
z.play.trigger.fadeOutFrames = 128;
|
z.play.trigger.fadeOutFrames = 128;
|
||||||
z.play.pitchEngine = PitchEngine::Varispeed;
|
z.play.pitchEngine = PitchEngine::Varispeed;
|
||||||
z.play.pitchEnv.enabled = true;
|
z.play.pitchEnv.enabled = true;
|
||||||
z.play.pitchEnv.attackFrames = 10;
|
z.play.pitchEnv.attackSeconds = 0.0002; // wall-clock seconds
|
||||||
z.play.pitchEnv.decayFrames = 500;
|
z.play.pitchEnv.decaySeconds = 0.011;
|
||||||
z.play.pitchEnv.peakSemitones = -7.5;
|
z.play.pitchEnv.peakSemitones = -7.5;
|
||||||
m.zones.push_back(z);
|
m.zones.push_back(z);
|
||||||
const PerformanceMap back = deserializePerformance(serializePerformance(m));
|
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
|
||||||
CHECK(back.zones.size() == 1);
|
CHECK(back.zones.size() == 1);
|
||||||
if (back.zones.size() != 1) return;
|
if (back.zones.size() != 1) return;
|
||||||
const ZonePlayParams& p = back.zones[0].play;
|
const ZonePlaySeconds& p = back.zones[0].play;
|
||||||
CHECK(p.playMode == PlayMode::Trigger);
|
CHECK(p.playMode == PlayMode::Trigger);
|
||||||
CHECK(p.adsr.holdFrames == 1234);
|
CHECK(p.adsr.holdSeconds == 0.028); // exact double round-trip
|
||||||
CHECK(p.trigger.lengthFraction == 0.375); // exact double round-trip
|
CHECK(p.trigger.lengthFraction == 0.375); // exact double round-trip
|
||||||
CHECK(p.trigger.fadeInFrames == 64);
|
CHECK(p.trigger.fadeInFrames == 64);
|
||||||
CHECK(p.trigger.fadeOutFrames == 128);
|
CHECK(p.trigger.fadeOutFrames == 128);
|
||||||
CHECK(p.pitchEngine == PitchEngine::Varispeed);
|
CHECK(p.pitchEngine == PitchEngine::Varispeed);
|
||||||
CHECK(p.pitchEnv.enabled == true);
|
CHECK(p.pitchEnv.enabled == true);
|
||||||
CHECK(p.pitchEnv.attackFrames == 10);
|
CHECK(p.pitchEnv.attackSeconds == 0.0002);
|
||||||
CHECK(p.pitchEnv.decayFrames == 500);
|
CHECK(p.pitchEnv.decaySeconds == 0.011);
|
||||||
CHECK(p.pitchEnv.peakSemitones == -7.5); // exact double round-trip
|
CHECK(p.pitchEnv.peakSemitones == -7.5); // exact double round-trip
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,16 +1188,16 @@ static void testPlayParamsComposeWithLoopStart() {
|
|||||||
z.loopOverride = lp;
|
z.loopOverride = lp;
|
||||||
z.startPoint = 333;
|
z.startPoint = 333;
|
||||||
z.play.playMode = PlayMode::Gate;
|
z.play.playMode = PlayMode::Gate;
|
||||||
z.play.adsr.holdFrames = 999;
|
z.play.adsr.holdSeconds = 0.0225;
|
||||||
z.play.pitchEngine = PitchEngine::Preserve;
|
z.play.pitchEngine = PitchEngine::Preserve;
|
||||||
m.zones.push_back(z);
|
m.zones.push_back(z);
|
||||||
const PerformanceMap back = deserializePerformance(serializePerformance(m));
|
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
|
||||||
CHECK(back.zones.size() == 1);
|
CHECK(back.zones.size() == 1);
|
||||||
if (back.zones.size() != 1) return;
|
if (back.zones.size() != 1) return;
|
||||||
CHECK(back.zones[0].loopOverride.has_value() &&
|
CHECK(back.zones[0].loopOverride.has_value() &&
|
||||||
back.zones[0].loopOverride->start == 111 && back.zones[0].loopOverride->end == 222);
|
back.zones[0].loopOverride->start == 111 && back.zones[0].loopOverride->end == 222);
|
||||||
CHECK(back.zones[0].startPoint.has_value() && *back.zones[0].startPoint == 333);
|
CHECK(back.zones[0].startPoint.has_value() && *back.zones[0].startPoint == 333);
|
||||||
CHECK(back.zones[0].play.adsr.holdFrames == 999);
|
CHECK(back.zones[0].play.adsr.holdSeconds == 0.0225);
|
||||||
CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve);
|
CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1107,7 +1222,7 @@ static void testPlayParamsV2BackCompatLiftsToDefaults() {
|
|||||||
b.push_back(0); // hasRootOverride = 0
|
b.push_back(0); // hasRootOverride = 0
|
||||||
b.push_back(0); // hasLoopOverride = 0
|
b.push_back(0); // hasLoopOverride = 0
|
||||||
b.push_back(0); // hasStartPoint = 0 (record ends here in v2)
|
b.push_back(0); // hasStartPoint = 0 (record ends here in v2)
|
||||||
const PerformanceMap back = deserializePerformance(b);
|
const PerformanceMap back = deserializePerformance(b, 44100.0);
|
||||||
CHECK(back.zones.size() == 1);
|
CHECK(back.zones.size() == 1);
|
||||||
if (back.zones.size() != 1) return;
|
if (back.zones.size() != 1) return;
|
||||||
CHECK(back.zones[0].sampleId == "old");
|
CHECK(back.zones[0].sampleId == "old");
|
||||||
@@ -1115,7 +1230,7 @@ static void testPlayParamsV2BackCompatLiftsToDefaults() {
|
|||||||
CHECK(back.zones[0].play.playMode == PlayMode::Gate);
|
CHECK(back.zones[0].play.playMode == PlayMode::Gate);
|
||||||
CHECK(back.zones[0].play.pitchEngine == kDefaultPitchEngine); // == Preserve
|
CHECK(back.zones[0].play.pitchEngine == kDefaultPitchEngine); // == Preserve
|
||||||
CHECK(back.zones[0].play.pitchEnv.enabled == false);
|
CHECK(back.zones[0].play.pitchEnv.enabled == false);
|
||||||
CHECK(back.zones[0].play.adsr.holdFrames == 0);
|
CHECK(back.zones[0].play.adsr.holdSeconds == 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testPlayParamsThroughComponentEnvelope() {
|
static void testPlayParamsThroughComponentEnvelope() {
|
||||||
@@ -1129,7 +1244,7 @@ static void testPlayParamsThroughComponentEnvelope() {
|
|||||||
z.play.trigger.lengthFraction = 0.9;
|
z.play.trigger.lengthFraction = 0.9;
|
||||||
z.play.pitchEngine = PitchEngine::Varispeed;
|
z.play.pitchEngine = PitchEngine::Varispeed;
|
||||||
s.map.zones.push_back(z);
|
s.map.zones.push_back(z);
|
||||||
const ComponentState back = deserializeComponentState(serializeComponentState(s));
|
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||||
CHECK(back.channelMode == ChannelMode::Stereo);
|
CHECK(back.channelMode == ChannelMode::Stereo);
|
||||||
CHECK(back.map.zones.size() == 1);
|
CHECK(back.map.zones.size() == 1);
|
||||||
if (back.map.zones.size() != 1) return;
|
if (back.map.zones.size() != 1) return;
|
||||||
@@ -1138,6 +1253,151 @@ static void testPlayParamsThroughComponentEnvelope() {
|
|||||||
CHECK(back.map.zones[0].play.pitchEngine == PitchEngine::Varispeed);
|
CHECK(back.map.zones[0].play.pitchEngine == PitchEngine::Varispeed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- S12 domain fix: wall-clock ADSR stored as SECONDS, resolved to frames at the live rate. ---
|
||||||
|
//
|
||||||
|
// These tests replace the R1/R2 flag/nominal-frame tests. The stored domain is seconds (rate-free);
|
||||||
|
// the keymap build resolves seconds -> frames against whatever WAV rate is live. The lift ->
|
||||||
|
// commit -> reload sequence must stay rate-correct at every rate (the R2 blocker).
|
||||||
|
|
||||||
|
// All five AHDSR fields round-trip through the v5 payload as SECONDS (exact double round-trip).
|
||||||
|
static void testFullAdsrSecondsRoundTrip() {
|
||||||
|
PerformanceMap m;
|
||||||
|
PerformanceZone z = zone("pad", 0, 127);
|
||||||
|
z.play.playMode = PlayMode::Gate;
|
||||||
|
z.play.adsr.attackSeconds = 0.01;
|
||||||
|
z.play.adsr.holdSeconds = 0.02;
|
||||||
|
z.play.adsr.decaySeconds = 0.1;
|
||||||
|
z.play.adsr.sustainLevel = 0.7;
|
||||||
|
z.play.adsr.releaseSeconds = 0.2;
|
||||||
|
z.play.pitchEngine = PitchEngine::Preserve;
|
||||||
|
m.zones.push_back(z);
|
||||||
|
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
|
||||||
|
CHECK(back.zones.size() == 1);
|
||||||
|
if (back.zones.size() != 1) return;
|
||||||
|
const AdsrSeconds& a = back.zones[0].play.adsr;
|
||||||
|
CHECK(a.attackSeconds == 0.01);
|
||||||
|
CHECK(a.holdSeconds == 0.02);
|
||||||
|
CHECK(a.decaySeconds == 0.1);
|
||||||
|
CHECK(a.sustainLevel == 0.7); // exact double round-trip via bit-cast
|
||||||
|
CHECK(a.releaseSeconds == 0.2);
|
||||||
|
CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A legacy PAYLOAD v3 blob (Daniel's beta projects — has holdFrames but no A/D/S/R) lifts the
|
||||||
|
// absent A/D/S/R to the tier-0 SECONDS defaults (0.003 / 0 / 1.0 / 0.060), NO rate involved: they
|
||||||
|
// were always the seconds constants. holdSeconds converts from the v3 44.1k-nominal frame count.
|
||||||
|
static void testV3BlobLiftsAdsrToSecondsDefaults() {
|
||||||
|
std::vector<std::uint8_t> blob;
|
||||||
|
auto u32 = [&](std::uint32_t v) {
|
||||||
|
blob.push_back(v & 0xFF); blob.push_back((v >> 8) & 0xFF);
|
||||||
|
blob.push_back((v >> 16) & 0xFF); blob.push_back((v >> 24) & 0xFF);
|
||||||
|
};
|
||||||
|
u32(kPerformanceStateVersion); // envelope version 2 header
|
||||||
|
const std::vector<std::uint8_t> payload = handBuildV3PayloadOneZone("old");
|
||||||
|
blob.insert(blob.end(), payload.begin(), payload.end());
|
||||||
|
const PerformanceMap back = deserializePerformance(blob, 44100.0);
|
||||||
|
CHECK(back.zones.size() == 1);
|
||||||
|
if (back.zones.size() != 1) return;
|
||||||
|
const AdsrSeconds& a = back.zones[0].play.adsr;
|
||||||
|
// hold converts from the v3 record's frames at the passed project rate (44100.0 here).
|
||||||
|
CHECK(approx(a.holdSeconds, 2048.0 / 44100.0)); // from the hand-built v3 record
|
||||||
|
CHECK(a.attackSeconds == AdsrSeconds{}.attackSeconds); // 0.003 (tier-0 default seconds)
|
||||||
|
CHECK(a.decaySeconds == AdsrSeconds{}.decaySeconds); // 0.0
|
||||||
|
CHECK(a.sustainLevel == AdsrSeconds{}.sustainLevel); // 1.0
|
||||||
|
CHECK(a.releaseSeconds == AdsrSeconds{}.releaseSeconds); // 0.060
|
||||||
|
}
|
||||||
|
|
||||||
|
// A legacy PAYLOAD v3 blob decoded at 96k: the hold frame count (2048) converts using the
|
||||||
|
// PASSED project rate, not a baked 44100 constant. At 96000 the seconds value is 2048/96000.
|
||||||
|
static void testV3BlobLiftsAdsrAt96k() {
|
||||||
|
std::vector<std::uint8_t> blob;
|
||||||
|
auto u32 = [&](std::uint32_t v) {
|
||||||
|
blob.push_back(v & 0xFF); blob.push_back((v >> 8) & 0xFF);
|
||||||
|
blob.push_back((v >> 16) & 0xFF); blob.push_back((v >> 24) & 0xFF);
|
||||||
|
};
|
||||||
|
u32(kPerformanceStateVersion); // envelope version 2 header
|
||||||
|
const std::vector<std::uint8_t> payload = handBuildV3PayloadOneZone("old96k");
|
||||||
|
blob.insert(blob.end(), payload.begin(), payload.end());
|
||||||
|
const PerformanceMap back = deserializePerformance(blob, 96000.0);
|
||||||
|
CHECK(back.zones.size() == 1);
|
||||||
|
if (back.zones.size() != 1) return;
|
||||||
|
const AdsrSeconds& a = back.zones[0].play.adsr;
|
||||||
|
// 2048 frames at 96000 Hz -> 2048/96000 seconds (not 2048/44100).
|
||||||
|
CHECK(approx(a.holdSeconds, 2048.0 / 96000.0));
|
||||||
|
CHECK(a.attackSeconds == AdsrSeconds{}.attackSeconds);
|
||||||
|
CHECK(a.decaySeconds == AdsrSeconds{}.decaySeconds);
|
||||||
|
CHECK(a.sustainLevel == AdsrSeconds{}.sustainLevel);
|
||||||
|
CHECK(a.releaseSeconds == AdsrSeconds{}.releaseSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lift -> commit -> reload sequence must stay rate-correct at 44.1k / 48k / 96k. A DEFAULT zone
|
||||||
|
// resolves to the tier-0 wall-clock durations at each rate (round(0.003*rate), round(0.060*rate));
|
||||||
|
// an AUTHORED zone resolves to round(seconds*rate). This is the R2 blocker, pinned across rates.
|
||||||
|
static void testKeymapBuildResolvesSecondsToFramesAtEachRate() {
|
||||||
|
const auto rnd = [](double s, int rate) {
|
||||||
|
return static_cast<std::int64_t>(s * static_cast<double>(rate) + 0.5);
|
||||||
|
};
|
||||||
|
for (int rate : {44100, 48000, 96000}) {
|
||||||
|
// (a) DEFAULT zone (round-tripped through serialize/deserialize) -> tier-0 seconds.
|
||||||
|
{
|
||||||
|
PerformanceMap m;
|
||||||
|
m.zones.push_back(zone("def", 0, 127)); // product-default play (tier-0 AHDSR seconds)
|
||||||
|
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
|
||||||
|
CHECK(back.zones.size() == 1);
|
||||||
|
if (back.zones.size() != 1) continue;
|
||||||
|
ResolvedZone rz; rz.lowNote = 0; rz.highNote = 127; rz.rootNote = 60;
|
||||||
|
rz.play = back.zones[0].play;
|
||||||
|
const DecodedZonePcm pcm{{0.5f}, rate};
|
||||||
|
const Keymap km = buildZonedKeymap({rz}, {pcm});
|
||||||
|
CHECK(km.samples.size() == 1);
|
||||||
|
if (km.samples.empty()) continue;
|
||||||
|
const AdsrParams& a = km.samples[0].play.adsr;
|
||||||
|
CHECK(a.attackFrames == rnd(0.003, rate)); // tier-0 attack at this rate
|
||||||
|
CHECK(a.decayFrames == 0);
|
||||||
|
CHECK(a.sustainLevel == 1.0); // level, never rate-scaled
|
||||||
|
CHECK(a.releaseFrames == rnd(0.060, rate)); // tier-0 release at this rate
|
||||||
|
}
|
||||||
|
// (b) AUTHORED zone -> round(seconds * rate) at this rate.
|
||||||
|
{
|
||||||
|
PerformanceMap m;
|
||||||
|
PerformanceZone z = zone("auth", 0, 127);
|
||||||
|
z.play.adsr.attackSeconds = 0.01;
|
||||||
|
z.play.adsr.decaySeconds = 0.1;
|
||||||
|
z.play.adsr.sustainLevel = 0.7;
|
||||||
|
z.play.adsr.releaseSeconds = 0.2;
|
||||||
|
m.zones.push_back(z);
|
||||||
|
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
|
||||||
|
CHECK(back.zones.size() == 1);
|
||||||
|
if (back.zones.size() != 1) continue;
|
||||||
|
ResolvedZone rz; rz.lowNote = 0; rz.highNote = 127; rz.rootNote = 60;
|
||||||
|
rz.play = back.zones[0].play;
|
||||||
|
const DecodedZonePcm pcm{{0.5f}, rate};
|
||||||
|
const Keymap km = buildZonedKeymap({rz}, {pcm});
|
||||||
|
CHECK(km.samples.size() == 1);
|
||||||
|
if (km.samples.empty()) continue;
|
||||||
|
const AdsrParams& a = km.samples[0].play.adsr;
|
||||||
|
CHECK(a.attackFrames == rnd(0.01, rate));
|
||||||
|
CHECK(a.decayFrames == rnd(0.1, rate));
|
||||||
|
CHECK(a.sustainLevel == 0.7); // level, never rate-scaled
|
||||||
|
CHECK(a.releaseFrames == rnd(0.2, rate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTier0Keymap resolves the default (seconds) play arg to frames at the WAV's rate — the
|
||||||
|
// single-capture fast path. At 48k the tier-0 attack is round(0.003*48000)=144, release
|
||||||
|
// round(0.060*48000)=2880 — identical wall-clock to any rate, no baked constant.
|
||||||
|
static void testBuildTier0KeymapResolvesSecondsAt48k() {
|
||||||
|
const Keymap km = buildTier0Keymap({0.5f}, 48000, 60, SampleLoop{});
|
||||||
|
CHECK(km.samples.size() == 1);
|
||||||
|
if (km.samples.empty()) return;
|
||||||
|
const AdsrParams& a = km.samples[0].play.adsr;
|
||||||
|
CHECK(a.attackFrames == 144); // round(0.003 * 48000)
|
||||||
|
CHECK(a.decayFrames == 0);
|
||||||
|
CHECK(a.sustainLevel == 1.0); // level, not a time
|
||||||
|
CHECK(a.releaseFrames == 2880); // round(0.060 * 48000)
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
testSelectByIdHit();
|
testSelectByIdHit();
|
||||||
testSelectEmptyIdIsSilence();
|
testSelectEmptyIdIsSilence();
|
||||||
@@ -1158,7 +1418,6 @@ int main() {
|
|||||||
testDownmixThreeChannelAverages();
|
testDownmixThreeChannelAverages();
|
||||||
testDownmixDegenerate();
|
testDownmixDegenerate();
|
||||||
testBuildKeymapSingleFullZone();
|
testBuildKeymapSingleFullZone();
|
||||||
testBuildKeymapRateDefault();
|
|
||||||
testSelectionStateRoundTrip();
|
testSelectionStateRoundTrip();
|
||||||
testSelectionStateEmptyId();
|
testSelectionStateEmptyId();
|
||||||
testSelectionStateWrongVersion();
|
testSelectionStateWrongVersion();
|
||||||
@@ -1189,6 +1448,11 @@ int main() {
|
|||||||
testPlayParamsComposeWithLoopStart();
|
testPlayParamsComposeWithLoopStart();
|
||||||
testPlayParamsV2BackCompatLiftsToDefaults();
|
testPlayParamsV2BackCompatLiftsToDefaults();
|
||||||
testPlayParamsThroughComponentEnvelope();
|
testPlayParamsThroughComponentEnvelope();
|
||||||
|
testFullAdsrSecondsRoundTrip();
|
||||||
|
testV3BlobLiftsAdsrToSecondsDefaults();
|
||||||
|
testV3BlobLiftsAdsrAt96k();
|
||||||
|
testKeymapBuildResolvesSecondsToFramesAtEachRate();
|
||||||
|
testBuildTier0KeymapResolvesSecondsAt48k();
|
||||||
testComponentStateRoundTrip();
|
testComponentStateRoundTrip();
|
||||||
testComponentStateLoopStartRoundTrip();
|
testComponentStateLoopStartRoundTrip();
|
||||||
testComponentStateSelectionOnlyNoZones();
|
testComponentStateSelectionOnlyNoZones();
|
||||||
@@ -1216,6 +1480,8 @@ int main() {
|
|||||||
testComponentStateDefaultMarkerIsZero();
|
testComponentStateDefaultMarkerIsZero();
|
||||||
testComponentStateV4LiftsMarkerToZero();
|
testComponentStateV4LiftsMarkerToZero();
|
||||||
testComponentStateV5TruncatedMarker();
|
testComponentStateV5TruncatedMarker();
|
||||||
|
testV5EnvelopeWithMarkerAndPlayParamsRoundTrip();
|
||||||
|
testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay();
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
+110
-50
@@ -149,7 +149,7 @@ static void testRepitchObservedPeriod() {
|
|||||||
// Unity: played at root, observed period ~= native.
|
// Unity: played at root, observed period ~= native.
|
||||||
{
|
{
|
||||||
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
|
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
|
||||||
VoiceEngine eng(4, km, flatAdsr());
|
VoiceEngine eng(4, km);
|
||||||
eng.noteOn(60, 127);
|
eng.noteOn(60, 127);
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
eng.render(out, frames);
|
eng.render(out, frames);
|
||||||
@@ -159,7 +159,7 @@ static void testRepitchObservedPeriod() {
|
|||||||
// +1 octave: advances 2x, observed period halves.
|
// +1 octave: advances 2x, observed period halves.
|
||||||
{
|
{
|
||||||
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
|
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
|
||||||
VoiceEngine eng(4, km, flatAdsr());
|
VoiceEngine eng(4, km);
|
||||||
eng.noteOn(72, 127);
|
eng.noteOn(72, 127);
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
eng.render(out, frames / 2); // half as many frames covers the whole sample
|
eng.render(out, frames / 2); // half as many frames covers the whole sample
|
||||||
@@ -169,7 +169,7 @@ static void testRepitchObservedPeriod() {
|
|||||||
// -1 octave: advances 0.5x, observed period doubles.
|
// -1 octave: advances 0.5x, observed period doubles.
|
||||||
{
|
{
|
||||||
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
|
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
|
||||||
VoiceEngine eng(4, km, flatAdsr());
|
VoiceEngine eng(4, km);
|
||||||
eng.noteOn(48, 127);
|
eng.noteOn(48, 127);
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
eng.render(out, frames);
|
eng.render(out, frames);
|
||||||
@@ -285,7 +285,7 @@ static void testAdsrZeroAttackDecay() {
|
|||||||
|
|
||||||
static void testPolyphonicAllocation() {
|
static void testPolyphonicAllocation() {
|
||||||
Keymap km = Keymap::singleSampleChromatic(dcSample(1000, 60));
|
Keymap km = Keymap::singleSampleChromatic(dcSample(1000, 60));
|
||||||
VoiceEngine eng(8, km, flatAdsr());
|
VoiceEngine eng(8, km);
|
||||||
|
|
||||||
// Four simultaneous notes -> four active voices, each on a distinct voice.
|
// Four simultaneous notes -> four active voices, each on a distinct voice.
|
||||||
std::size_t v60 = eng.noteOn(60, 100);
|
std::size_t v60 = eng.noteOn(60, 100);
|
||||||
@@ -329,10 +329,11 @@ static void testNoteOffReleasesNewestSameNote() {
|
|||||||
const double gainOld = velOld / 127.0; // ~0.504
|
const double gainOld = velOld / 127.0; // ~0.504
|
||||||
const double gainNew = velNew / 127.0; // 1.0
|
const double gainNew = velNew / 127.0; // 1.0
|
||||||
|
|
||||||
Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60));
|
SampleData sd = dcSample(100000, 60);
|
||||||
AdsrParams a = flatAdsr();
|
sd.play.adsr = flatAdsr();
|
||||||
a.releaseFrames = 10; // short but non-zero so voice stays active through release
|
sd.play.adsr.releaseFrames = 10; // short but non-zero so voice stays active through release
|
||||||
VoiceEngine eng(8, km, a);
|
Keymap km = Keymap::singleSampleChromatic(sd);
|
||||||
|
VoiceEngine eng(8, km);
|
||||||
|
|
||||||
std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain
|
std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain
|
||||||
std::size_t second = eng.noteOn(60, velNew); // newer voice, higher gain
|
std::size_t second = eng.noteOn(60, velNew); // newer voice, higher gain
|
||||||
@@ -371,7 +372,7 @@ static void testOutOfZoneNoteConsumesNoVoice() {
|
|||||||
Keymap km;
|
Keymap km;
|
||||||
km.samples.push_back(dcSample(100, 60));
|
km.samples.push_back(dcSample(100, 60));
|
||||||
km.zones.push_back(KeyZone{60, 72, 60, 0});
|
km.zones.push_back(KeyZone{60, 72, 60, 0});
|
||||||
VoiceEngine eng(4, km, flatAdsr());
|
VoiceEngine eng(4, km);
|
||||||
|
|
||||||
std::size_t v = eng.noteOn(30, 100); // below the only zone
|
std::size_t v = eng.noteOn(30, 100); // below the only zone
|
||||||
CHECK(v == VoiceEngine::kNoVoice);
|
CHECK(v == VoiceEngine::kNoVoice);
|
||||||
@@ -383,10 +384,13 @@ static void testOutOfZoneNoteConsumesNoVoice() {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
static void testStealsReleasingVoiceFirst() {
|
static void testStealsReleasingVoiceFirst() {
|
||||||
Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60));
|
// Long per-zone release so the voice stays active through the release tail. Voice::start reads
|
||||||
AdsrParams a = flatAdsr();
|
// sample.play.adsr (the engine holds no ADSR), so the long release lives on the SampleData.
|
||||||
a.releaseFrames = 100000; // long release so a released voice stays "active".
|
SampleData s = dcSample(100000, 60);
|
||||||
VoiceEngine eng(2, km, a);
|
s.play.adsr = flatAdsr();
|
||||||
|
s.play.adsr.releaseFrames = 100000; // long release so a released voice stays "active"
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine eng(2, km);
|
||||||
|
|
||||||
std::size_t vA = eng.noteOn(60, 100); // startOrder 1
|
std::size_t vA = eng.noteOn(60, 100); // startOrder 1
|
||||||
std::size_t vB = eng.noteOn(62, 100); // startOrder 2
|
std::size_t vB = eng.noteOn(62, 100); // startOrder 2
|
||||||
@@ -406,10 +410,12 @@ static void testStealsReleasingVoiceFirst() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void testStealsOldestWhenNoneReleasing() {
|
static void testStealsOldestWhenNoneReleasing() {
|
||||||
Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60));
|
// Long per-zone release — placed on SampleData.play.adsr per the S12 fix.
|
||||||
AdsrParams a = flatAdsr();
|
SampleData s = dcSample(100000, 60);
|
||||||
a.releaseFrames = 100000;
|
s.play.adsr = flatAdsr();
|
||||||
VoiceEngine eng(2, km, a);
|
s.play.adsr.releaseFrames = 100000;
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine eng(2, km);
|
||||||
|
|
||||||
std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest)
|
std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest)
|
||||||
std::size_t vB = eng.noteOn(62, 100); // startOrder 2
|
std::size_t vB = eng.noteOn(62, 100); // startOrder 2
|
||||||
@@ -447,7 +453,7 @@ static void testLoopSustainSeamless() {
|
|||||||
s.loop.end = 40;
|
s.loop.end = 40;
|
||||||
|
|
||||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127); // unity ratio, full velocity
|
eng.noteOn(60, 127); // unity ratio, full velocity
|
||||||
|
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
@@ -469,7 +475,7 @@ static void testZeroLengthLoopGoesSilent() {
|
|||||||
s.loop.start = 25;
|
s.loop.start = 25;
|
||||||
s.loop.end = 25; // zero length
|
s.loop.end = 25; // zero length
|
||||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127);
|
eng.noteOn(60, 127);
|
||||||
|
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
@@ -493,7 +499,7 @@ static void testSingleFrameLoop() {
|
|||||||
s.loop.end = 6; // single-frame loop: [5, 6)
|
s.loop.end = 6; // single-frame loop: [5, 6)
|
||||||
|
|
||||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127); // unity ratio, full velocity
|
eng.noteOn(60, 127); // unity ratio, full velocity
|
||||||
|
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
@@ -512,7 +518,7 @@ static void testAbsentLoopGoesSilent() {
|
|||||||
SampleData s = dcSample(50, 60);
|
SampleData s = dcSample(50, 60);
|
||||||
// s.loop.hasLoop stays false.
|
// s.loop.hasLoop stays false.
|
||||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127);
|
eng.noteOn(60, 127);
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
eng.render(out, 100);
|
eng.render(out, 100);
|
||||||
@@ -533,7 +539,7 @@ static void testStartFrameOffsetsInitialRead() {
|
|||||||
s.rootNote = 60;
|
s.rootNote = 60;
|
||||||
s.startFrame = 30;
|
s.startFrame = 30;
|
||||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
|
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
eng.render(out, 3);
|
eng.render(out, 3);
|
||||||
@@ -549,7 +555,7 @@ static void testStartFrameZeroIsUnchanged() {
|
|||||||
for (int i = 0; i < 20; ++i) s.frames[i] = static_cast<float>(i) * 0.05f;
|
for (int i = 0; i < 20; ++i) s.frames[i] = static_cast<float>(i) * 0.05f;
|
||||||
s.rootNote = 60; // startFrame stays 0
|
s.rootNote = 60; // startFrame stays 0
|
||||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127);
|
eng.noteOn(60, 127);
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
eng.render(out, 1);
|
eng.render(out, 1);
|
||||||
@@ -562,7 +568,7 @@ static void testStartFrameOutOfRangeClampsToZero() {
|
|||||||
SampleData s = dcSample(10, 60); // 10 frames of 1.0
|
SampleData s = dcSample(10, 60); // 10 frames of 1.0
|
||||||
s.startFrame = 10; // == frameCount: out of range
|
s.startFrame = 10; // == frameCount: out of range
|
||||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127);
|
eng.noteOn(60, 127);
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
eng.render(out, 5);
|
eng.render(out, 5);
|
||||||
@@ -583,7 +589,7 @@ static void testStartFrameWithLoop() {
|
|||||||
s.loop.start = 20;
|
s.loop.start = 20;
|
||||||
s.loop.end = 40;
|
s.loop.end = 40;
|
||||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127);
|
eng.noteOn(60, 127);
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
eng.render(out, 200);
|
eng.render(out, 200);
|
||||||
@@ -612,7 +618,7 @@ static void testStartAfterLoopEndWrapsIntoLoop() {
|
|||||||
s.loop.end = 40;
|
s.loop.end = 40;
|
||||||
|
|
||||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127); // unity ratio, full velocity
|
eng.noteOn(60, 127); // unity ratio, full velocity
|
||||||
|
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
@@ -638,21 +644,21 @@ static void testVelocityToVolume() {
|
|||||||
// Full velocity -> full gain; half velocity -> ~half gain (flat envelope so the
|
// Full velocity -> full gain; half velocity -> ~half gain (flat envelope so the
|
||||||
// rendered value is exactly velocity/127 on a DC-1 sample).
|
// rendered value is exactly velocity/127 on a DC-1 sample).
|
||||||
{
|
{
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127);
|
eng.noteOn(60, 127);
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
eng.render(out, 1);
|
eng.render(out, 1);
|
||||||
CHECK(approx(out[0], 1.0, 1e-4));
|
CHECK(approx(out[0], 1.0, 1e-4));
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 64);
|
eng.noteOn(60, 64);
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
eng.render(out, 1);
|
eng.render(out, 1);
|
||||||
CHECK(approx(out[0], 64.0 / 127.0, 1e-4));
|
CHECK(approx(out[0], 64.0 / 127.0, 1e-4));
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 1);
|
eng.noteOn(60, 1);
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
eng.render(out, 1);
|
eng.render(out, 1);
|
||||||
@@ -663,7 +669,7 @@ static void testVelocityToVolume() {
|
|||||||
// Two voices summed: polyphony mixes additively.
|
// Two voices summed: polyphony mixes additively.
|
||||||
static void testPolyphonyMixesAdditively() {
|
static void testPolyphonyMixesAdditively() {
|
||||||
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
|
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
|
||||||
VoiceEngine eng(4, km, flatAdsr());
|
VoiceEngine eng(4, km);
|
||||||
eng.noteOn(60, 127); // gain 1.0
|
eng.noteOn(60, 127); // gain 1.0
|
||||||
eng.noteOn(60, 127); // gain 1.0 (second voice, same note)
|
eng.noteOn(60, 127); // gain 1.0 (second voice, same note)
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
@@ -699,7 +705,7 @@ static void testStereoRenderKeepsChannelsDistinct() {
|
|||||||
// A stereo sample (L=1.0, R=-1.0) rendered stereo must emit L and R distinctly, each
|
// A stereo sample (L=1.0, R=-1.0) rendered stereo must emit L and R distinctly, each
|
||||||
// scaled by velocity (full here). If the engine copied L to both channels the R check fails.
|
// scaled by velocity (full here). If the engine copied L to both channels the R check fails.
|
||||||
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
|
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127);
|
eng.noteOn(60, 127);
|
||||||
|
|
||||||
std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
|
std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
|
||||||
@@ -714,7 +720,7 @@ static void testMonoSamplePlaysDualMonoInStereo() {
|
|||||||
// A MONO sample rendered through the stereo path plays dual-mono: both channels equal
|
// A MONO sample rendered through the stereo path plays dual-mono: both channels equal
|
||||||
// (centered), not silent on the right. The cross-mode "mono source in stereo mode" case.
|
// (centered), not silent on the right. The cross-mode "mono source in stereo mode" case.
|
||||||
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // mono, DC 1.0
|
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // mono, DC 1.0
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127);
|
eng.noteOn(60, 127);
|
||||||
std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
|
std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
|
||||||
eng.render(left.data(), right.data(), 8);
|
eng.render(left.data(), right.data(), 8);
|
||||||
@@ -728,7 +734,7 @@ static void testMonoRenderUnchangedByStereoData() {
|
|||||||
// Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical
|
// Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical
|
||||||
// whether or not a second channel is present. A stereo sample rendered mono == its L channel.
|
// whether or not a second channel is present. A stereo sample rendered mono == its L channel.
|
||||||
Keymap kmS = Keymap::singleSampleChromatic(stereoDcSample(100, 0.75f, -0.25f, 60));
|
Keymap kmS = Keymap::singleSampleChromatic(stereoDcSample(100, 0.75f, -0.25f, 60));
|
||||||
VoiceEngine engS(1, kmS, flatAdsr());
|
VoiceEngine engS(1, kmS);
|
||||||
engS.noteOn(60, 127);
|
engS.noteOn(60, 127);
|
||||||
std::vector<AudioSample> mono;
|
std::vector<AudioSample> mono;
|
||||||
engS.render(mono, 8); // the mono overload
|
engS.render(mono, 8); // the mono overload
|
||||||
@@ -753,7 +759,7 @@ static void testStereoRenderAdvancesLikeMonoRepitch() {
|
|||||||
}
|
}
|
||||||
s.rootNote = 60;
|
s.rootNote = 60;
|
||||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
VoiceEngine eng(4, km, flatAdsr());
|
VoiceEngine eng(4, km);
|
||||||
eng.noteOn(72, 127); // +1 octave
|
eng.noteOn(72, 127); // +1 octave
|
||||||
std::vector<AudioSample> left(frames / 2, 0.f), right(frames / 2, 0.f);
|
std::vector<AudioSample> left(frames / 2, 0.f), right(frames / 2, 0.f);
|
||||||
eng.render(left.data(), right.data(), frames / 2);
|
eng.render(left.data(), right.data(), frames / 2);
|
||||||
@@ -764,7 +770,7 @@ static void testStereoRenderAdvancesLikeMonoRepitch() {
|
|||||||
static void testStereoRenderSumsVoicesPerChannel() {
|
static void testStereoRenderSumsVoicesPerChannel() {
|
||||||
// Two voices on a stereo sample sum PER CHANNEL (additive polyphony holds in stereo).
|
// Two voices on a stereo sample sum PER CHANNEL (additive polyphony holds in stereo).
|
||||||
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 0.5f, -0.5f, 60));
|
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 0.5f, -0.5f, 60));
|
||||||
VoiceEngine eng(4, km, flatAdsr());
|
VoiceEngine eng(4, km);
|
||||||
eng.noteOn(60, 127);
|
eng.noteOn(60, 127);
|
||||||
eng.noteOn(60, 127); // second voice, same note
|
eng.noteOn(60, 127); // second voice, same note
|
||||||
std::vector<AudioSample> left(1, 0.f), right(1, 0.f);
|
std::vector<AudioSample> left(1, 0.f), right(1, 0.f);
|
||||||
@@ -775,7 +781,7 @@ static void testStereoRenderSumsVoicesPerChannel() {
|
|||||||
|
|
||||||
static void testStereoRenderNullBufferIsNoOp() {
|
static void testStereoRenderNullBufferIsNoOp() {
|
||||||
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
|
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127);
|
eng.noteOn(60, 127);
|
||||||
std::vector<AudioSample> buf(4, 0.f);
|
std::vector<AudioSample> buf(4, 0.f);
|
||||||
eng.render(nullptr, buf.data(), 4); // null left -> no-op, no crash
|
eng.render(nullptr, buf.data(), 4); // null left -> no-op, no crash
|
||||||
@@ -804,7 +810,7 @@ static void testStereoStartFrameLoopShareOneReadHead() {
|
|||||||
s.loop.end = 30; // loop [20,30): frames 20..29
|
s.loop.end = 30; // loop [20,30): frames 20..29
|
||||||
CHECK(s.channelCount() == 2);
|
CHECK(s.channelCount() == 2);
|
||||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
|
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
|
||||||
|
|
||||||
std::vector<AudioSample> left(200, 0.f), right(200, 0.f);
|
std::vector<AudioSample> left(200, 0.f), right(200, 0.f);
|
||||||
@@ -902,7 +908,7 @@ static SampleData triggerSample(std::size_t frames, double lengthFraction,
|
|||||||
static void testTriggerLengthFractionFrames() {
|
static void testTriggerLengthFractionFrames() {
|
||||||
// 200-frame sample, start 0, 50% length -> plays 100 frames then the voice frees.
|
// 200-frame sample, start 0, 50% length -> plays 100 frames then the voice frees.
|
||||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
|
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127); // unity ratio
|
eng.noteOn(60, 127); // unity ratio
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
eng.render(out, 200);
|
eng.render(out, 200);
|
||||||
@@ -916,7 +922,7 @@ static void testTriggerLengthFractionFrames() {
|
|||||||
static void testTriggerLengthWithStart() {
|
static void testTriggerLengthWithStart() {
|
||||||
// 200 frames, start 40, 50% -> span 160, play 80 frames (frames 40..119), then free.
|
// 200 frames, start 40, 50% -> span 160, play 80 frames (frames 40..119), then free.
|
||||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0, /*start=*/40));
|
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0, /*start=*/40));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127);
|
eng.noteOn(60, 127);
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
eng.render(out, 200);
|
eng.render(out, 200);
|
||||||
@@ -930,7 +936,7 @@ static void testTriggerFadeShape() {
|
|||||||
// 100 frames, 100% length, fadeIn 20, fadeOut 20. Head ramps 0->1, tail ramps 1->0, unity
|
// 100 frames, 100% length, fadeIn 20, fadeOut 20. Head ramps 0->1, tail ramps 1->0, unity
|
||||||
// between. Equal-power: sin/cos ramps, monotonic, endpoints ~0 and ~1.
|
// between. Equal-power: sin/cos ramps, monotonic, endpoints ~0 and ~1.
|
||||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 1.0, 20, 20));
|
Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 1.0, 20, 20));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127);
|
eng.noteOn(60, 127);
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
eng.render(out, 120);
|
eng.render(out, 120);
|
||||||
@@ -950,7 +956,7 @@ static void testTriggerEdgeCases() {
|
|||||||
// %=0: zero play length -> voice frees at once, no sound.
|
// %=0: zero play length -> voice frees at once, no sound.
|
||||||
{
|
{
|
||||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 0.0, 5, 5));
|
Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 0.0, 5, 5));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127);
|
eng.noteOn(60, 127);
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
eng.render(out, 50);
|
eng.render(out, 50);
|
||||||
@@ -961,7 +967,7 @@ static void testTriggerEdgeCases() {
|
|||||||
{
|
{
|
||||||
// 40 frames, 100% -> playLen 40; fadeIn 30 + fadeOut 30 = 60 > 40 -> clamped.
|
// 40 frames, 100% -> playLen 40; fadeIn 30 + fadeOut 30 = 60 > 40 -> clamped.
|
||||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(40, 1.0, 30, 30));
|
Keymap km = Keymap::singleSampleChromatic(triggerSample(40, 1.0, 30, 30));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127);
|
eng.noteOn(60, 127);
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
eng.render(out, 50);
|
eng.render(out, 50);
|
||||||
@@ -971,7 +977,7 @@ static void testTriggerEdgeCases() {
|
|||||||
// %=100 plays the full post-start span.
|
// %=100 plays the full post-start span.
|
||||||
{
|
{
|
||||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(60, 1.0, 0, 0));
|
Keymap km = Keymap::singleSampleChromatic(triggerSample(60, 1.0, 0, 0));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127);
|
eng.noteOn(60, 127);
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
eng.render(out, 80);
|
eng.render(out, 80);
|
||||||
@@ -983,7 +989,7 @@ static void testTriggerEdgeCases() {
|
|||||||
// --- Trigger ignores note-off (S15): the one-shot plays through regardless. ---
|
// --- Trigger ignores note-off (S15): the one-shot plays through regardless. ---
|
||||||
static void testTriggerIgnoresNoteOff() {
|
static void testTriggerIgnoresNoteOff() {
|
||||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
|
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127);
|
eng.noteOn(60, 127);
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
eng.render(out, 10);
|
eng.render(out, 10);
|
||||||
@@ -1033,7 +1039,7 @@ static void testPreserveDurationInvariance() {
|
|||||||
|
|
||||||
auto lengthAt = [&](int note) -> std::size_t {
|
auto lengthAt = [&](int note) -> std::size_t {
|
||||||
Keymap km = Keymap::singleSampleChromatic(preserveTriggerSample(frames, 1.0));
|
Keymap km = Keymap::singleSampleChromatic(preserveTriggerSample(frames, 1.0));
|
||||||
VoiceEngine eng(1, km, flatAdsr(), /*preserveCap=*/0, /*window=*/static_cast<std::int64_t>(window));
|
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/static_cast<std::int64_t>(window));
|
||||||
eng.noteOn(note, 127);
|
eng.noteOn(note, 127);
|
||||||
return soundingLength(eng, 4000);
|
return soundingLength(eng, 4000);
|
||||||
};
|
};
|
||||||
@@ -1060,7 +1066,7 @@ static void testVarispeedStillCouplesDuration() {
|
|||||||
s.play.pitchEngine = PitchEngine::Varispeed;
|
s.play.pitchEngine = PitchEngine::Varispeed;
|
||||||
s.play.trigger.lengthFraction = 1.0;
|
s.play.trigger.lengthFraction = 1.0;
|
||||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(note, 127);
|
eng.noteOn(note, 127);
|
||||||
return soundingLength(eng, 4000);
|
return soundingLength(eng, 4000);
|
||||||
};
|
};
|
||||||
@@ -1085,7 +1091,7 @@ static void testPitchEnvOffBitIdentical() {
|
|||||||
s.play.pitchEnv.decayFrames = 500;
|
s.play.pitchEnv.decayFrames = 500;
|
||||||
}
|
}
|
||||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(67, 127); // a transposed note so ratio != 1 (exercises the ratio path)
|
eng.noteOn(67, 127); // a transposed note so ratio != 1 (exercises the ratio path)
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
eng.render(out, n);
|
eng.render(out, n);
|
||||||
@@ -1113,7 +1119,7 @@ static void testPitchEnvOnBendsVarispeed() {
|
|||||||
s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames
|
s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames
|
||||||
s.play.pitchEnv.peakSemitones = 12.0; // +1 octave at t=0
|
s.play.pitchEnv.peakSemitones = 12.0; // +1 octave at t=0
|
||||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
VoiceEngine eng(1, km, flatAdsr());
|
VoiceEngine eng(1, km);
|
||||||
eng.noteOn(60, 127); // at root -> base ratio 1.0; the env supplies the bend
|
eng.noteOn(60, 127); // at root -> base ratio 1.0; the env supplies the bend
|
||||||
std::vector<AudioSample> out;
|
std::vector<AudioSample> out;
|
||||||
eng.render(out, 4000);
|
eng.render(out, 4000);
|
||||||
@@ -1148,7 +1154,7 @@ static void testPreserveGateStereoLoopComposes() {
|
|||||||
s.play.pitchEngine = PitchEngine::Preserve;
|
s.play.pitchEngine = PitchEngine::Preserve;
|
||||||
CHECK(s.channelCount() == 2);
|
CHECK(s.channelCount() == 2);
|
||||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
VoiceEngine eng(1, km, flatAdsr(), 0, 512);
|
VoiceEngine eng(1, km, 0, 512);
|
||||||
eng.noteOn(67, 127); // transposed up a fifth under Preserve (duration held)
|
eng.noteOn(67, 127); // transposed up a fifth under Preserve (duration held)
|
||||||
std::vector<AudioSample> left(2000, 0.f), right(2000, 0.f);
|
std::vector<AudioSample> left(2000, 0.f), right(2000, 0.f);
|
||||||
eng.render(left.data(), right.data(), 2000);
|
eng.render(left.data(), right.data(), 2000);
|
||||||
@@ -1170,13 +1176,63 @@ static void testPreserveVoiceCap() {
|
|||||||
s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough)
|
s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough)
|
||||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
// 8 voices total, Preserve cap of 2.
|
// 8 voices total, Preserve cap of 2.
|
||||||
VoiceEngine eng(8, km, flatAdsr(), /*preserveCap=*/2, /*window=*/256);
|
VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256);
|
||||||
CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice
|
CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice
|
||||||
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap)
|
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap)
|
||||||
CHECK(eng.noteOn(64, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap
|
CHECK(eng.noteOn(64, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap
|
||||||
CHECK(eng.activeVoiceCount() == 2);
|
CHECK(eng.activeVoiceCount() == 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Per-zone A/D/S/R actually reaches the voice envelope (S12). ---
|
||||||
|
//
|
||||||
|
// Every AHDSR field rides on SampleData.play.adsr (frames, resolved from the stored seconds at
|
||||||
|
// keymap build); the engine holds no instrument-wide ADSR. These two tests assert that path.
|
||||||
|
|
||||||
|
// The zone's attackFrames drives the envelope ramp. Strategy: put an explicit 10-frame attack on
|
||||||
|
// the SampleData.play.adsr. If Voice::start reads the zone ADSR, the DC-1 output will be 0 at frame
|
||||||
|
// 0 and 1.0 after the 10-frame ramp; a voice that ignored the zone ADSR (instant) would already be
|
||||||
|
// 1.0 at frame 0. This is the load-bearing proof.
|
||||||
|
static void testPerZoneAdsrReachesVoiceEnvelope() {
|
||||||
|
SampleData s = dcSample(500, 60);
|
||||||
|
// Per-zone attack = 10 frames, zero decay, sustain 1.0, zero release.
|
||||||
|
s.play.adsr.attackFrames = 10;
|
||||||
|
s.play.adsr.holdFrames = 0;
|
||||||
|
s.play.adsr.decayFrames = 0;
|
||||||
|
s.play.adsr.sustainLevel = 1.0;
|
||||||
|
s.play.adsr.releaseFrames = 0;
|
||||||
|
s.play.pitchEngine = PitchEngine::Varispeed; // isolate from pitch engine machinery
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine eng(1, km);
|
||||||
|
eng.noteOn(60, 127); // unity pitch, full velocity -> gain 1.0
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
eng.render(out, 20);
|
||||||
|
// Frame 0: attack start, envelope near 0. A voice ignoring the zone ADSR would read 1.0 here.
|
||||||
|
CHECK(approx(out[0], 0.0, 1e-9)); // env still at bottom of ramp
|
||||||
|
// Frame 9: still ramping (last attack frame, linear ramp reaches 0.9).
|
||||||
|
CHECK(out[9] < 1.0 - 1e-9);
|
||||||
|
// Frame 10+: attack complete, sustain at 1.0.
|
||||||
|
CHECK(approx(out[10], 1.0, 1e-9));
|
||||||
|
CHECK(approx(out[19], 1.0, 1e-9));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default-valued zone (AdsrParams all zeros) is behavior-identical to the pre-fix flat path.
|
||||||
|
// A zero-init AdsrParams (attackFrames=0, decayFrames=0, sustainLevel=1.0, releaseFrames=0) must
|
||||||
|
// yield an instant-attack/instant-sustain voice — frame 0 immediately at 1.0. This preserves the
|
||||||
|
// back-compat invariant: an old zone with no A/D/S/R storage sounds the same as before.
|
||||||
|
static void testZeroAdsrIsInstantSustain() {
|
||||||
|
SampleData s = dcSample(20, 60);
|
||||||
|
// Default AdsrParams{}: all zeros, sustainLevel = 1.0 (struct default). No attack ramp.
|
||||||
|
s.play.adsr = AdsrParams{};
|
||||||
|
s.play.pitchEngine = PitchEngine::Varispeed;
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine eng(1, km);
|
||||||
|
eng.noteOn(60, 127);
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
eng.render(out, 5);
|
||||||
|
// All frames must be 1.0: zero attack + sustain 1.0 = instantly at full level.
|
||||||
|
for (std::size_t i = 0; i < out.size(); ++i) CHECK(approx(out[i], 1.0, 1e-9));
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
testChromaticSingleRoot();
|
testChromaticSingleRoot();
|
||||||
testZonedRangesBoundaries();
|
testZonedRangesBoundaries();
|
||||||
@@ -1228,6 +1284,10 @@ int main() {
|
|||||||
testPreserveGateStereoLoopComposes();
|
testPreserveGateStereoLoopComposes();
|
||||||
testPreserveVoiceCap();
|
testPreserveVoiceCap();
|
||||||
|
|
||||||
|
// S12 review fix — per-zone A/D/S/R reaches the voice envelope.
|
||||||
|
testPerZoneAdsrReachesVoiceEnvelope();
|
||||||
|
testZeroAdsrIsInstantSustain();
|
||||||
|
|
||||||
if (g_fail == 0) {
|
if (g_fail == 0) {
|
||||||
std::printf("all sampler_core tests passed\n");
|
std::printf("all sampler_core tests passed\n");
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user