From 4f30124ef79d4757da26becf7dbede934edf869d Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 01:09:19 -0400 Subject: [PATCH 1/6] S12: editor scale + S15/S16 control surfaces Pure browser_scroll/note_entry/param_slider modules (+ CTest) for scroll, type-to-filter search, numeric note entry, and the AHDSR/Trigger/pitch-engine/ pitch-env control panel. Editor shell draws + routes through them; params edit the selected zone's ZonePlayParams via commitAndReload. --- CMakeLists.txt | 48 +++- PLAN.md | 33 ++- src/vst/browser_scroll.cpp | 158 +++++++++++ src/vst/browser_scroll.h | 107 ++++++++ src/vst/note_entry.cpp | 113 ++++++++ src/vst/note_entry.h | 33 +++ src/vst/param_slider.cpp | 92 +++++++ src/vst/param_slider.h | 104 ++++++++ src/vst/reasampler_editor.cpp | 487 ++++++++++++++++++++++++++++++++-- src/vst/reasampler_editor.h | 67 ++++- tests/test_browser_scroll.cpp | 179 +++++++++++++ tests/test_note_entry.cpp | 73 +++++ tests/test_param_slider.cpp | 204 ++++++++++++++ 13 files changed, 1667 insertions(+), 31 deletions(-) create mode 100644 src/vst/browser_scroll.cpp create mode 100644 src/vst/browser_scroll.h create mode 100644 src/vst/note_entry.cpp create mode 100644 src/vst/note_entry.h create mode 100644 src/vst/param_slider.cpp create mode 100644 src/vst/param_slider.h create mode 100644 tests/test_browser_scroll.cpp create mode 100644 tests/test_note_entry.cpp create mode 100644 tests/test_param_slider.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 62d8175..c036e60 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -716,6 +716,31 @@ add_library(bank_sync STATIC src/vst/bank_sync.cpp) target_include_directories(bank_sync PUBLIC src/vst src) target_link_libraries(bank_sync PUBLIC assignment_request) +# browser_scroll (Phase S12) — PURE scroll-window + scrollbar-thumb + type-to-filter-search +# geometry LAYERED over the S10 capture_browser: the visible-card window, thumb rect + +# thumb-drag<->offset mapping, and the name-substring filter that composes with the bank +# filter. The mirror of capture_browser; links capture_browser (for BrowserLayout + the card +# metrics/cell rect) which pulls editor_geometry transitively. NEITHER SDK. +add_library(browser_scroll STATIC src/vst/browser_scroll.cpp) +target_include_directories(browser_scroll PUBLIC src/vst) +target_link_libraries(browser_scroll PUBLIC capture_browser) + +# note_entry (Phase S12) — PURE text->clamped-MIDI-note parse for the direct numeric entry of +# a zone's low/high/root (decimal integer OR note name under the C4==60 convention, clamped to +# [0,127]). No dependency beyond the standard library. NEITHER SDK. +add_library(note_entry STATIC src/vst/note_entry.cpp) +target_include_directories(note_entry PUBLIC src/vst) + +# param_slider (Phase S12 + the S15/S16 control surfaces deferred here) — PURE control-surface +# layout + hit-test + normalized value<->pixel mapping for the editor parameter panel (the +# Gate|Trigger + Varispeed|Preserve toggles and the AHDSR / Trigger / pitch-env sliders). The +# mirror of keyboard_strip; links editor_geometry for the shared Rect. Deliberately engine-free +# (no sampler_core types) — the shell owns the control-id -> param binding + the value DOMAIN +# mapping. NEITHER SDK. +add_library(param_slider STATIC src/vst/param_slider.cpp) +target_include_directories(param_slider PUBLIC src/vst) +target_link_libraries(param_slider PUBLIC editor_geometry) + add_executable(editor_geometry_tests tests/test_editor_geometry.cpp) target_link_libraries(editor_geometry_tests PRIVATE editor_geometry) add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests) @@ -755,6 +780,22 @@ add_executable(bank_sync_tests tests/test_bank_sync.cpp) target_link_libraries(bank_sync_tests PRIVATE bank_sync) add_test(NAME bank_sync_tests COMMAND bank_sync_tests) +# browser_scroll (S12): the pure scroll-window/thumb + search geometry over capture_browser. +add_executable(browser_scroll_tests tests/test_browser_scroll.cpp) +target_link_libraries(browser_scroll_tests PRIVATE browser_scroll) +add_test(NAME browser_scroll_tests COMMAND browser_scroll_tests) + +# note_entry (S12): the pure text->clamped-MIDI-note parse for direct numeric entry. +add_executable(note_entry_tests tests/test_note_entry.cpp) +target_link_libraries(note_entry_tests PRIVATE note_entry) +add_test(NAME note_entry_tests COMMAND note_entry_tests) + +# param_slider (S12 + S15/S16 control surfaces): the pure control-panel layout + slider/toggle +# value<->pixel mapping the editor parameter surface draws + routes against. +add_executable(param_slider_tests tests/test_param_slider.cpp) +target_link_libraries(param_slider_tests PRIVATE param_slider) +add_test(NAME param_slider_tests COMMAND param_slider_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -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 # processor's off-thread poll runs; links assignment_request transitively (the decoded # request it consumes) — the same key the extension writes, shared via the pure module. + # browser_scroll + note_entry + param_slider (S12 + S15/S16 control surfaces): the pure + # scroll/search geometry over the capture browser, the numeric-note-entry parse, and the + # control-panel layout + slider/toggle value<->pixel mapping the editor's parameter surface + # draws + routes against. browser_scroll pulls capture_browser transitively; param_slider + + # note_entry link editor_geometry / the stdlib only. All engine-free, DAW-verified in the shell. target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal sample_map capture_paths embed_strip app_version capture_browser keyboard_strip - waveform_view bank_sync) + waveform_view bank_sync browser_scroll note_entry param_slider) # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC}) diff --git a/PLAN.md b/PLAN.md index b2b2c99..dea473b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 > 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 the drawn cards to matching display names, **composing with S10's bank filter** (bank filter selects the bank; search narrows within it). Scroll/search layout + hit-test is pure geometry (visible-card window, scrollbar thumb rect, search-box rect), layered over - S10's `capture_browser` module; filter/scroll state is transient UI state. -- [ ] Direct numeric entry for zone low/high/root: a click-to-edit field over the strip - (LICE text-entry idiom or a SWELL edit control on the child HWND) so a precise note can be - typed, not only dragged. Commits via `commitMapAndReload` like every other edit. -- [ ] ADSR editor: four draggable controls (attack/decay/sustain/release) over the S3 - `AdsrParams`; per-instance component state (additive to the map/selection blob, version- - bumped, back-compat defaults to the current fixed envelope). Slider layout/hit-test pure; - the audible envelope change goes through the same off-thread reload. + S10's `capture_browser` module; filter/scroll state is transient UI state. **Landed:** + pure `browser_scroll` module (`browser_scroll_tests`) — scrollContentHeight / max / clamp, + visibleCardRange window, scrolledCardCellRect, scrollThumbRect + thumbDragToOffset inverse, + searchBoxRect, nameMatchesQuery + filterNameIndices. Editor shell wires wheel (`WM_MOUSEWHEEL`), + thumb-drag (`DragKind::kScrollThumb`), and the search box (`WM_CHAR` -> `onSearchChar`, + composed into `rebuildVisible`). Scroll/search are transient (never persisted). +- [x] Direct numeric entry for zone low/high/root: a click-to-edit field over the strip + (LICE text-entry idiom) so a precise note can be typed, not only dragged. Commits via + `commitAndReload` like every other edit. **Landed:** pure `note_entry` module + (`note_entry_tests`) — `parseNoteEntry` accepts a decimal integer OR a note name (C4==60), + clamps to [0,127], rejects garbage. Editor shell hosts three focusable fields (low/high/root) + on the Zones legend, committing on Enter through `commitAndReload`. +- [x] ADSR/AHDSR editor + S15/S16 control surfaces: draggable sliders over the S3 `AdsrParams` + (attack/**hold**/decay/sustain/release — hold is the S15 addition) plus the deferred S15/S16 + controls — per-zone Gate|Trigger mode toggle, Trigger %-length/fade-in/fade-out, Varispeed| + Preserve engine toggle, and the AD pitch-envelope enable/attack/decay/±semitone depth. All + edit the SELECTED zone's `ZonePlayParams` (instrument-owned, D-B; never the bank), round-trip + through the existing v3 component-state blob (no new persistence — the S15/S16 payload already + landed in the core pass), and commit off-thread via `commitAndReload`. **Landed:** pure + `param_slider` module (`param_slider_tests`) — control-panel stack layout, toggle-segment + split + hit-test, slider value<->pixel round-trip + clamping, point->control routing. The + shell owns the control-id -> engine-param binding + the value DOMAIN mapping (frames/fraction/ + semitones); the module stays engine-free. ## S13 — drop-to-load: the S8 ingest story, folded into the editor UX (UX overhaul, part 4) **Goal:** Make "load a sample into the sampler" **one gesture from the editor**: dropping diff --git a/src/vst/browser_scroll.cpp b/src/vst/browser_scroll.cpp new file mode 100644 index 0000000..85be0f8 --- /dev/null +++ b/src/vst/browser_scroll.cpp @@ -0,0 +1,158 @@ +// browser_scroll.cpp — see browser_scroll.h. PURE scroll + search geometry over the S10 +// capture_browser. No host types; only the shared Rect + BrowserLayout. + +#include "browser_scroll.h" + +#include +#include + +namespace reasampler::vst { + +namespace { +// The minimum thumb height so a very long bank still yields a grabbable thumb. +constexpr int kMinThumbHeight = 20; + +char asciiLower(char c) { + return static_cast(std::tolower(static_cast(c))); +} +} // namespace + +int scrollContentHeight(const BrowserLayout& layout, int cardCount) { + if (cardCount <= 0) return 0; + const int columns = (std::max)(1, layout.columns); + const int rows = (cardCount + columns - 1) / columns; // ceil + return rows * kBrowserCardHeight; +} + +int scrollMaxOffset(const BrowserLayout& layout, int cardCount) { + const int content = scrollContentHeight(layout, cardCount); + const int gridH = (std::max)(0, layout.grid.height()); + return (std::max)(0, content - gridH); +} + +int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset) { + const int maxOff = scrollMaxOffset(layout, cardCount); + if (proposedOffset < 0) return 0; + if (proposedOffset > maxOff) return maxOff; + return proposedOffset; +} + +VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset) { + VisibleRange vr; + if (cardCount <= 0) return vr; + const int columns = (std::max)(1, layout.columns); + const int gridH = (std::max)(0, layout.grid.height()); + if (gridH <= 0 || kBrowserCardHeight <= 0) { + vr.first = 0; + vr.last = 0; + return vr; + } + if (offset < 0) offset = 0; + // First visible ROW: the topmost row whose bottom edge is below the offset. Floor so a row + // partially scrolled off the top still draws (its lower part is visible). + const int firstRow = offset / kBrowserCardHeight; + // Last visible ROW: the row containing the pixel (offset + gridH - 1), inclusive; +1 for + // the exclusive end. A row straddling the bottom edge still draws. + const int lastRow = (offset + gridH - 1) / kBrowserCardHeight + 1; + int first = firstRow * columns; + int last = lastRow * columns; + if (first > cardCount) first = cardCount; + if (last > cardCount) last = cardCount; + if (last < first) last = first; + vr.first = first; + vr.last = last; + return vr; +} + +Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset) { + Rect r = cardCellRect(layout, index); + if (r.right <= r.left && r.bottom <= r.top) return r; // empty (negative index) stays empty + return Rect{r.left, r.top - offset, r.right, r.bottom - offset}; +} + +Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset) { + const int content = scrollContentHeight(layout, cardCount); + const int gridH = (std::max)(0, layout.grid.height()); + if (content <= gridH || gridH <= 0) return Rect{}; // fits -> no scrollbar + const int maxOff = content - gridH; + if (offset < 0) offset = 0; + if (offset > maxOff) offset = maxOff; + + const int trackRight = layout.grid.right; + const int trackLeft = trackRight - kScrollbarWidth; + const int trackTop = layout.grid.top; + + // Thumb height proportional to the visible fraction, floored at a grabbable minimum but + // never taller than the track. + int thumbH = static_cast(static_cast(gridH) * gridH / content); + thumbH = (std::max)(kMinThumbHeight, thumbH); + thumbH = (std::min)(thumbH, gridH); + + // Thumb top proportional to the offset over the movable track span. + const int trackSpan = gridH - thumbH; // >= 0 + int thumbTop = trackTop; + if (maxOff > 0 && trackSpan > 0) { + thumbTop = trackTop + static_cast( + static_cast(offset) * trackSpan / maxOff); + } + return Rect{trackLeft, thumbTop, trackRight, thumbTop + thumbH}; +} + +int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, + int dyPixels) { + const int content = scrollContentHeight(layout, cardCount); + const int gridH = (std::max)(0, layout.grid.height()); + if (content <= gridH || gridH <= 0) return clampScrollOffset(layout, cardCount, startOffset); + + // Thumb height (same formula as scrollThumbRect) -> movable track span in thumb pixels. + int thumbH = static_cast(static_cast(gridH) * gridH / content); + thumbH = (std::max)(kMinThumbHeight, thumbH); + thumbH = (std::min)(thumbH, gridH); + const int trackSpan = gridH - thumbH; + if (trackSpan <= 0) return clampScrollOffset(layout, cardCount, startOffset); + + const int maxOff = content - gridH; + // A 1px thumb move covers maxOff/trackSpan content px. Round to nearest for symmetry. + const long long deltaOffset = + (static_cast(dyPixels) * maxOff + (dyPixels >= 0 ? trackSpan / 2 : -trackSpan / 2)) / + trackSpan; + const long long proposed = static_cast(startOffset) + deltaOffset; + if (proposed < 0) return 0; + if (proposed > maxOff) return maxOff; + return static_cast(proposed); +} + +Rect searchBoxRect(int w) { + if (w <= 0) return Rect{}; + return Rect{0, 0, w, kSearchBoxHeight}; +} + +bool nameMatchesQuery(const std::string& name, const std::string& query) { + if (query.empty()) return true; + if (query.size() > name.size()) return false; + // Case-insensitive substring scan (ASCII fold). Small strings; a naive scan is fine. + for (std::size_t i = 0; i + query.size() <= name.size(); ++i) { + bool match = true; + for (std::size_t j = 0; j < query.size(); ++j) { + if (asciiLower(name[i + j]) != asciiLower(query[j])) { + match = false; + break; + } + } + if (match) return true; + } + return false; +} + +std::vector filterNameIndices(const std::vector& names, + const std::string& query) { + std::vector out; + out.reserve(names.size()); + for (int i = 0; i < static_cast(names.size()); ++i) { + if (nameMatchesQuery(names[static_cast(i)], query)) + out.push_back(i); + } + return out; +} + +} // namespace reasampler::vst diff --git a/src/vst/browser_scroll.h b/src/vst/browser_scroll.h new file mode 100644 index 0000000..81aa831 --- /dev/null +++ b/src/vst/browser_scroll.h @@ -0,0 +1,107 @@ +// browser_scroll.h — PURE scroll + type-to-filter geometry LAYERED over the S10 +// capture_browser. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of +// capture_browser / editor_geometry: the fiddly scroll-window + scrollbar-thumb + search-box +// arithmetic lives here, unit-tested outside the DAW, while the editor shell draws the +// clipped card window + the scrollbar + the search field and routes wheel/drag/keystrokes +// into these functions. +// +// WHY IT EXISTS (S12). capture_browser (S10) lays out EVERY card top-down and the shell +// clips at the browser bottom — a bank longer than the panel runs off with no way to reach +// it (the S12 gap). This module adds the two things S12 layers over that stable geometry: +// * SCROLL — a vertical pixel offset into the card grid, with the max-offset clamp, the +// visible-row window, a scrollbar thumb rect, and the thumb-drag<->offset mapping so a +// wheel tick or a thumb drag reaches every card; and +// * SEARCH — a name-substring filter (case-insensitive) that narrows the drawn cards, +// COMPOSING with capture_browser's bank filter (the shell applies the bank filter first, +// then this search narrows within it) + the search-box rect the shell draws the field in. +// +// It holds NO card data and draws nothing — it knows only the browser layout (from +// capture_browser), COUNTS, and the scroll OFFSET the shell owns as transient UI state. It +// reuses capture_browser's BrowserLayout + the shared Rect (one geometry idiom). + +#pragma once + +#include +#include + +#include "capture_browser.h" // BrowserLayout, cardCellRect, kBrowserCardHeight, Rect + +namespace reasampler::vst { + +// The width (px) of the vertical scrollbar gutter at the right edge of the grid. The shell +// draws the track + thumb here and hit-tests thumb grabs against scrollThumbRect. Exposed so +// the shell and tests agree. When the content fits (no scroll needed) the scrollbar is +// suppressed (scrollThumbRect returns empty) and the shell may reclaim the gutter. +inline constexpr int kScrollbarWidth = 10; + +// The height (px) of the type-to-filter search box the shell draws ABOVE the tab strip (a +// thin band spanning the browser width). Exposed so the shell reserves the band and tests +// agree. capture_browser's tab strip + grid sit BELOW this band (the shell offsets the +// BrowserLayout it feeds to capture_browser by kSearchBoxHeight). +inline constexpr int kSearchBoxHeight = 22; + +// The total pixel HEIGHT the card grid needs to draw all `cardCount` cards at `layout`'s +// column count: the number of ROWS (ceil(cardCount / columns)) times the fixed cell height. +// Zero cards -> 0. Pure — the content extent the scroll offset ranges over. +int scrollContentHeight(const BrowserLayout& layout, int cardCount); + +// The maximum scroll offset (px): content height minus the visible grid height, floored at 0. +// When the content fits within the grid this is 0 (nothing to scroll). Pure — the clamp +// ceiling for every offset the shell tracks. +int scrollMaxOffset(const BrowserLayout& layout, int cardCount); + +// Clamp a proposed scroll offset into [0, scrollMaxOffset]. The shell clamps after every wheel +// tick / thumb drag so an over-scroll pins to an edge rather than showing past the last card +// or above the first. Pure. +int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset); + +// The half-open range of card INDICES [first, last) at least partially visible in the grid at +// scroll `offset`. The shell draws only these cards (the S12 clip window) rather than every +// card. `offset` is assumed pre-clamped (the shell clamps on input); a first past the last row +// yields an empty range (first==last==cardCount). Pure. +struct VisibleRange { + int first = 0; // first card index drawn (inclusive) + int last = 0; // one past the last card index drawn (exclusive) +}; +VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset); + +// The cell rect of card `index` SHIFTED UP by the scroll offset, ready to draw (the shell +// still adds the browser sub-area origin). Equivalent to capture_browser::cardCellRect with +// the offset subtracted from top/bottom. Pure — the one place the offset applies to a card. +Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset); + +// The vertical scrollbar THUMB rect within the grid's right-edge gutter, sized proportional to +// the visible fraction (grid height / content height) and positioned proportional to the +// scroll offset. Returns an EMPTY rect when the content fits (no scroll needed) — the shell +// suppresses the scrollbar then. A minimum thumb height keeps a tiny thumb grabbable on a very +// long bank. Pure — the geometry the shell draws + hit-tests the thumb grab against. +Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset); + +// Map a thumb-drag to a scroll offset. Given the offset the thumb held at grab time +// (`startOffset`) and the vertical pixel delta since grab (`dyPixels`), returns the new +// (clamped) scroll offset: startOffset shifted by the delta scaled from thumb-track pixels to +// content pixels (a 1px thumb move covers content/track px of content). A degenerate track / +// fitting content pins to startOffset. Pure — the inverse of scrollThumbRect's position map. +int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, int dyPixels); + +// The search-box rect: a full-width band of height kSearchBoxHeight at the TOP of the browser +// area (above where capture_browser's tab strip draws). `w` is the browser sub-area width; +// the shell adds its origin. A zero/negative width yields an empty rect. Pure. +Rect searchBoxRect(int w); + +// True iff `name` contains `query` as a case-insensitive ASCII substring. An EMPTY query +// matches everything (the no-filter identity). Matching is ASCII case-folded (the display +// names are ASCII until the Phase L type kit lands, mirroring the editor's other ASCII-only +// text). Pure — the single match predicate the shell's search narrow is built from. +bool nameMatchesQuery(const std::string& name, const std::string& query); + +// Narrow a list of display `names` to the INDICES whose name matches `query`, preserving +// order. An EMPTY query returns every index [0, names.size()) (the composition base so "bank +// filter, no search" == today's browser). Kept name-only (indices, not card structs) so this +// module stays free of the sample_map/bank_book chain — the shell owns the SampleChoice list +// and applies the bank filter FIRST, then feeds the surviving display names here (search +// narrows within the bank). Pure. +std::vector filterNameIndices(const std::vector& names, + const std::string& query); + +} // namespace reasampler::vst diff --git a/src/vst/note_entry.cpp b/src/vst/note_entry.cpp new file mode 100644 index 0000000..99cb354 --- /dev/null +++ b/src/vst/note_entry.cpp @@ -0,0 +1,113 @@ +// note_entry.cpp — see note_entry.h. PURE text->MIDI-note parse for the S12 numeric entry. + +#include "note_entry.h" + +#include +#include + +namespace reasampler::vst { + +namespace { +char asciiUpper(char c) { + return static_cast(std::toupper(static_cast(c))); +} + +std::string trim(const std::string& s) { + std::size_t a = 0; + std::size_t b = s.size(); + while (a < b && std::isspace(static_cast(s[a]))) ++a; + while (b > a && std::isspace(static_cast(s[b - 1]))) --b; + return s.substr(a, b - a); +} + +int clampNote(long long n) { + if (n < 0) return 0; + if (n > 127) return 127; + return static_cast(n); +} + +// Semitone offset within an octave for a note letter (C..B), or -1 for a non-letter. +int letterSemitone(char up) { + switch (up) { + case 'C': return 0; + case 'D': return 2; + case 'E': return 4; + case 'F': return 5; + case 'G': return 7; + case 'A': return 9; + case 'B': return 11; + default: return -1; + } +} + +// Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive). MIDI 0 == C-1, 60 == C4 +// (the DAW convention the editor's noteLabel uses). Returns nullopt if it is not a note name. +std::optional parseNoteName(const std::string& s) { + if (s.empty()) return std::nullopt; + std::size_t i = 0; + const int base = letterSemitone(asciiUpper(s[i])); + if (base < 0) return std::nullopt; // not a letter -> not a note name + ++i; + int semitone = base; + // Optional accidental(s): # / b (or 's'/'f' are NOT accepted — keep it to the two glyphs). + while (i < s.size() && (s[i] == '#' || s[i] == 'b' || s[i] == 'B')) { + // A trailing 'b'/'B' could be a flat OR the start of nothing; here after a letter it is + // an accidental. '#' raises, 'b'/'B' lowers. + if (s[i] == '#') ++semitone; + else --semitone; + ++i; + } + // The octave: an optional sign then digits, running to the end. + if (i >= s.size()) return std::nullopt; // a bare "C" has no octave -> reject (ambiguous) + bool neg = false; + if (s[i] == '+' || s[i] == '-') { + neg = (s[i] == '-'); + ++i; + } + if (i >= s.size()) return std::nullopt; + int octave = 0; + bool anyDigit = false; + for (; i < s.size(); ++i) { + if (!std::isdigit(static_cast(s[i]))) return std::nullopt; + octave = octave * 10 + (s[i] - '0'); + anyDigit = true; + } + if (!anyDigit) return std::nullopt; + if (neg) octave = -octave; + // MIDI note = (octave + 1) * 12 + semitone (C-1 == 0, C4 == 60). + const long long note = static_cast(octave + 1) * 12 + semitone; + return clampNote(note); +} + +std::optional parseInteger(const std::string& s) { + if (s.empty()) return std::nullopt; + std::size_t i = 0; + bool neg = false; + if (s[i] == '+' || s[i] == '-') { + neg = (s[i] == '-'); + ++i; + } + if (i >= s.size()) return std::nullopt; + long long v = 0; + for (; i < s.size(); ++i) { + if (!std::isdigit(static_cast(s[i]))) return std::nullopt; + v = v * 10 + (s[i] - '0'); + if (v > 1000000) v = 1000000; // saturate; clampNote takes it to 127 anyway + } + if (neg) v = -v; + return clampNote(v); +} +} // namespace + +std::optional parseNoteEntry(const std::string& text) { + const std::string s = trim(text); + if (s.empty()) return std::nullopt; + // Try a plain integer first (the common MIDI-number case); fall back to a note name. + if (std::isdigit(static_cast(s[0])) || s[0] == '+' || + (s[0] == '-' && s.size() > 1 && std::isdigit(static_cast(s[1])))) { + if (auto n = parseInteger(s)) return n; + } + return parseNoteName(s); +} + +} // namespace reasampler::vst diff --git a/src/vst/note_entry.h b/src/vst/note_entry.h new file mode 100644 index 0000000..de3ae0a --- /dev/null +++ b/src/vst/note_entry.h @@ -0,0 +1,33 @@ +// note_entry.h — PURE parse + clamp for the S12 direct numeric entry of a zone's +// low/high/root MIDI note. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The +// mirror of the other pure editor helpers: the fiddly text->note parse lives here, unit- +// tested outside the DAW, while the editor shell hosts the text field (a SWELL edit control +// or a LICE text-entry idiom) and feeds the committed string here on Enter. +// +// WHY IT EXISTS (S12). Low/high/root are draggable on the keyboard strip, but a drag can't +// hit a precise note reliably. This adds a typed field: the user clicks the field, types a +// value, and presses Enter; the shell hands the raw string here to parse into a clamped MIDI +// note [0,127] and commits via the same off-thread reload as every other edit. +// +// ACCEPTED FORMS (both, so a musician OR a MIDI-number user is served): +// * a plain decimal integer ("60", " 127 ", "+5") — the raw MIDI note number; and +// * a note name ("C4", "f#3", "Bb-1") — parsed to its MIDI number under the DAW's C4==60 +// convention (MIDI 0 == C-1, matching REAPER + the editor's noteLabel). +// A value out of [0,127] CLAMPS to the range (a typed 200 becomes 127) rather than +// rejecting — the least-surprising behavior for a nudge field. Unparseable input returns +// nullopt (the shell keeps the old value + may flash the field). + +#pragma once + +#include +#include + +namespace reasampler::vst { + +// Parse a typed low/high/root field into a clamped MIDI note [0,127]. Accepts a decimal +// integer OR a note name (see the header notes). Leading/trailing ASCII whitespace is +// ignored. An in-range parse returns the note; an out-of-range numeric or note value clamps +// into [0,127]; empty or unparseable input returns nullopt (no change). Pure — no host types. +std::optional parseNoteEntry(const std::string& text); + +} // namespace reasampler::vst diff --git a/src/vst/param_slider.cpp b/src/vst/param_slider.cpp new file mode 100644 index 0000000..a01df57 --- /dev/null +++ b/src/vst/param_slider.cpp @@ -0,0 +1,92 @@ +// param_slider.cpp — see param_slider.h. PURE control-surface geometry for the S12/S15/S16 +// editor parameter panel. No host types; only the shared Rect + contains(). + +#include "param_slider.h" + +#include + +namespace reasampler::vst { + +std::vector layoutControls(const Rect& panel, + const std::vector& controls) { + std::vector out; + if (controls.empty() || panel.width() <= 0 || panel.height() <= 0) return out; + out.reserve(controls.size()); + + // The label column is clamped so a narrow panel still leaves a control column. + const int labelW = (std::min)(kControlLabelWidth, (std::max)(0, panel.width() / 2)); + int rowTop = panel.top; + for (const ControlDesc& d : controls) { + ControlRow r; + r.id = d.id; + r.kind = d.kind; + const int rowBottom = rowTop + kControlRowHeight; + r.row = Rect{panel.left, rowTop, panel.right, rowBottom}; + r.label = Rect{panel.left, rowTop, panel.left + labelW, rowBottom}; + r.control = Rect{panel.left + labelW, rowTop, panel.right, rowBottom}; + out.push_back(r); + rowTop = rowBottom + kControlRowGap; + } + return out; +} + +Rect toggleSegmentRect(const Rect& control, int seg) { + if (seg < 0 || seg >= kToggleSegments) return Rect{}; + const int w = control.width(); + if (w <= 0 || control.height() <= 0) return Rect{}; + const int segW = w / kToggleSegments; + const int left = control.left + seg * segW; + // The last segment absorbs the width remainder so the segments tile the whole control. + const int right = (seg == kToggleSegments - 1) ? control.right : left + segW; + return Rect{left, control.top, right, control.bottom}; +} + +int toggleSegmentHitTest(const Rect& control, int x, int y) { + if (!contains(control, x, y)) return -1; + for (int seg = 0; seg < kToggleSegments; ++seg) { + if (contains(toggleSegmentRect(control, seg), x, y)) return seg; + } + return -1; +} + +Rect sliderTrackRect(const Rect& control) { + // Inset a half-handle at each end so the handle stays fully inside the control at value + // 0 and 1. The handle CENTER ranges across [track.left, track.right]. + const int half = kSliderHandleWidth / 2; + if (control.width() <= kSliderHandleWidth || control.height() <= 0) return Rect{}; + return Rect{control.left + half, control.top, control.right - half, control.bottom}; +} + +Rect sliderHandleRect(const Rect& control, double value) { + const Rect track = sliderTrackRect(control); + if (track.width() <= 0) return Rect{}; + if (value < 0.0) value = 0.0; + if (value > 1.0) value = 1.0; + const int span = track.width(); // handle-center movable span + const int centerX = track.left + static_cast(value * span + 0.5); + const int half = kSliderHandleWidth / 2; + return Rect{centerX - half, control.top, centerX - half + kSliderHandleWidth, + control.bottom}; +} + +double valueAtPoint(const Rect& control, int x) { + const Rect track = sliderTrackRect(control); + const int span = track.width(); + if (span <= 0) return 0.0; + if (x <= track.left) return 0.0; + if (x >= track.right) return 1.0; + return static_cast(x - track.left) / static_cast(span); +} + +int controlAtPoint(const std::vector& rows, int x, int y) { + for (const ControlRow& r : rows) { + if (r.kind == ControlKind::Toggle) { + if (contains(r.control, x, y)) return r.id; + } else { // Slider — the interactive area is the track + if (contains(sliderTrackRect(r.control), x, y)) return r.id; + } + } + return -1; +} + +} // namespace reasampler::vst diff --git a/src/vst/param_slider.h b/src/vst/param_slider.h new file mode 100644 index 0000000..60ebe14 --- /dev/null +++ b/src/vst/param_slider.h @@ -0,0 +1,104 @@ +// param_slider.h — PURE control-surface layout + hit-test + value<->pixel mapping for the +// S12/S15/S16 editor parameter panel. NO VST3, NO REAPER, NO SWELL/LICE types at the +// boundary, and — deliberately — NO sampler_core / sample_map engine types either. The +// mirror of keyboard_strip / waveform_view / mode_switch: the fiddly slider-track and +// toggle-segment arithmetic lives here, unit-tested outside the DAW, while the editor shell +// draws each row (label + track/segments + handle) and routes clicks/drags into these +// functions, owning the control-id -> engine-param binding + the value DOMAIN mapping. +// +// WHY IT EXISTS (S12 + the S15/S16 control surfaces deferred here). The setup / Zones surface +// grows a stack of parameter controls: the S15 play-mode toggle (Gate|Trigger), the AHDSR +// amp-envelope sliders (attack/hold/decay/sustain/release), the Trigger %-length + fade +// controls, the S16 Varispeed|Preserve engine toggle, and the AD pitch-envelope +// enable/attack/decay/depth. They are two shapes only — a two-segment TOGGLE and a +// horizontal SLIDER — laid out as a vertical stack of fixed-height rows. This module lays out +// that stack and maps a slider's NORMALIZED value (0..1) to/from its handle pixel; the shell +// converts each control's engine value (frames, seconds, a fraction, a signed semitone +// depth) to/from that 0..1 with its own domain knowledge (this module stays engine-free so it +// tests without the audio core). +// +// It reuses editor_geometry's Rect + contains() (one shared geometry idiom). + +#pragma once + +#include + +#include "editor_geometry.h" // Rect, contains — one shared geometry idiom + +namespace reasampler::vst { + +// Fixed control-panel metrics, exposed so the shell and tests agree. +inline constexpr int kControlRowHeight = 22; // one control row (incl. its inter-row gap) +inline constexpr int kControlRowGap = 4; // vertical gap below each row +inline constexpr int kControlLabelWidth = 92; // the label column at the row's left +inline constexpr int kSliderHandleWidth = 8; // the draggable slider handle width (px) +inline constexpr int kToggleSegments = 2; // a toggle is always two segments + +// A control is one of two shapes. Toggle = a two-segment selector (the active segment +// highlights); Slider = a horizontal track with a draggable handle over a 0..1 value. +enum class ControlKind { Toggle, Slider }; + +// One control the shell places in the panel, in stack order. `id` is the shell's own control +// identifier (an int the shell casts from its ControlId enum) returned by the hit-test so the +// shell routes the interaction to the right engine param — this module never interprets it. +struct ControlDesc { + int id = 0; + ControlKind kind = ControlKind::Slider; +}; + +// The laid-out geometry of one control row: its full row rect plus the interactive sub-rect +// (the track for a Slider, the whole control area for a Toggle — the shell splits a Toggle +// into segments via toggleSegmentRect). `index` is the control's position in the stack. +struct ControlRow { + int id = 0; + ControlKind kind = ControlKind::Slider; + Rect row; // the full row (label column + control column) + Rect label; // the label column at the left + Rect control; // the control column to the right of the label (track / toggle area) +}; + +// Lay out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down. Each +// row is kControlRowHeight tall with kControlRowGap below it; the label column takes the left +// kControlLabelWidth (clamped so it never exceeds the panel), the control column the rest. A +// row whose top falls past the panel bottom is still returned (the shell clips at paint / +// suppresses it) so the stack geometry is deterministic regardless of panel height. An empty +// control list or a degenerate panel yields an empty vector. Pure. +std::vector layoutControls(const Rect& panel, + const std::vector& controls); + +// The rect of segment `seg` (0..kToggleSegments-1) within a toggle control's `control` rect, +// splitting it into kToggleSegments equal segments left-to-right (the last absorbs any width +// remainder, mirror of mode_switch's segment split). An out-of-range segment or a degenerate +// control rect yields an empty rect. Pure. +Rect toggleSegmentRect(const Rect& control, int seg); + +// The toggle segment a point lands on within a toggle control's `control` rect, or -1 for a +// miss (outside the control area). Pure. +int toggleSegmentHitTest(const Rect& control, int x, int y); + +// The slider track sub-rect inside a slider control's `control` rect: the control inset so the +// handle (kSliderHandleWidth) stays fully within the control at value 0 and 1 (a half-handle +// margin at each end). The handle CENTER ranges across [track.left, track.right] as the value +// ranges [0,1]. The shell draws the track fill + handle here. A degenerate control yields an +// empty rect. Pure. +Rect sliderTrackRect(const Rect& control); + +// The handle rect for a slider at normalized `value` (clamped to [0,1]) within `control`: a +// kSliderHandleWidth-wide bar centered at the value's position along sliderTrackRect. A +// degenerate control yields an empty rect. Pure — the inverse of valueAtPoint. +Rect sliderHandleRect(const Rect& control, double value); + +// Map a point x to a normalized slider value [0,1] within `control` (the handle-center range). +// x at/left of the track start -> 0; at/right of the end -> 1; linear between. A degenerate +// track (zero movable span) -> 0. Pure — the inverse of sliderHandleRect's position map; the +// shell converts the returned 0..1 into its engine domain (frames/seconds/fraction/semitones). +double valueAtPoint(const Rect& control, int x); + +// The control a point lands on, given the laid-out `rows`. Returns the control id (ControlDesc +// id) whose interactive area (a Slider's track, a Toggle's whole control area) contains the +// point, or -1 for a miss (a gap, the label column, or outside every row). The FIRST matching +// row wins (rows never overlap, so at most one matches). Pure — the shell's routing entry +// point: on a hit it reads the value (valueAtPoint / toggleSegmentHitTest) and commits. +int controlAtPoint(const std::vector& rows, int x, int y); + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 590943c..f0e7df8 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -10,11 +10,14 @@ #include #include +#include "browser_scroll.h" // S12 scroll-window + thumb + type-to-filter search geometry #include "capture_browser.h" #include "capture_paths.h" // resolveBankFile (shared M4 path resolution) #include "editor_geometry.h" // Rect, contains #include "ext_keys.h" #include "keyboard_strip.h" +#include "note_entry.h" // S12 direct numeric note-entry parse +#include "param_slider.h" // S12/S15/S16 control-surface layout + value<->pixel mapping #include "peaks.h" // computeEnvelope #include "reaper_bridge.h" #include "reasampler_processor.h" @@ -175,11 +178,18 @@ void ReaSamplerEditor::refreshFromBank() { } void ReaSamplerEditor::rebuildVisible() { + // S12 composition: the bank filter picks the bank FIRST, then the type-to-filter search + // narrows the survivors by name substring (nameMatchesQuery — empty query is the identity). visible_.clear(); for (const SampleChoice& s : samples_) { - if (activeFilterBankId_.empty() || s.bankId == activeFilterBankId_) - visible_.push_back(s); + const bool inBank = activeFilterBankId_.empty() || s.bankId == activeFilterBankId_; + if (!inBank) continue; + const std::string& name = s.displayName.empty() ? s.id : s.displayName; + if (nameMatchesQuery(name, searchQuery_)) visible_.push_back(s); } + // NOTE: scrollOffset_ is clamped at paint + wheel time (where the browser layout / panel + // height is known); rebuildVisible runs cross-platform + on the sync-timer refresh, so it + // must not reset the user's scroll here. } #ifdef _WIN32 @@ -287,6 +297,102 @@ void ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) { } } +namespace { +// The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is +// engine-free and maps only 0..1). Time sliders span [0, max] frames at a nominal rate so a +// full-throw reaches a musically generous ceiling; the exact wall-clock is DAW-verified. These +// are build-time residuals (one place to retune), not persisted. +constexpr double kEnvTimeMaxFrames = 2.0 * 44100.0; // AHDSR A/H/D/R + pitch A/D throw ceiling +constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered + +double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); } +} // namespace + +std::vector ReaSamplerEditor::controlDescs(const ZonePlayParams& play) const { + std::vector out; + // Always: the two mode toggles. + out.push_back({static_cast(ParamControl::kPlayMode), ControlKind::Toggle}); + out.push_back({static_cast(ParamControl::kPitchEngine), ControlKind::Toggle}); + // Mode-relevant amplitude sliders. + if (play.playMode == PlayMode::Gate) { + out.push_back({static_cast(ParamControl::kAttack), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kHold), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kDecay), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kSustain), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kRelease), ControlKind::Slider}); + } else { // Trigger + out.push_back({static_cast(ParamControl::kTrigLength), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kTrigFadeIn), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kTrigFadeOut), ControlKind::Slider}); + } + // The AD pitch envelope: an enable toggle + its three sliders (drawn always; inert until on). + out.push_back({static_cast(ParamControl::kPitchEnvEnable), ControlKind::Toggle}); + out.push_back({static_cast(ParamControl::kPitchEnvAttack), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kPitchEnvDecay), ControlKind::Slider}); + out.push_back({static_cast(ParamControl::kPitchEnvDepth), ControlKind::Slider}); + return out; +} + +double ReaSamplerEditor::controlValue(int id, const ZonePlayParams& play) const { + const auto framesToNorm = [](std::int64_t f) { + return clamp01(static_cast(f) / kEnvTimeMaxFrames); + }; + switch (static_cast(id)) { + case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0; + case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0; + case ParamControl::kAttack: return framesToNorm(play.adsr.attackFrames); + case ParamControl::kHold: return framesToNorm(play.adsr.holdFrames); + case ParamControl::kDecay: return framesToNorm(play.adsr.decayFrames); + case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel); + case ParamControl::kRelease: return framesToNorm(play.adsr.releaseFrames); + 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 framesToNorm(play.pitchEnv.attackFrames); + case ParamControl::kPitchEnvDecay: return framesToNorm(play.pitchEnv.decayFrames); + 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, ZonePlayParams& play, double value, + int segment) const { + const auto normToFrames = [](double v) { + return static_cast(clamp01(v) * kEnvTimeMaxFrames + 0.5); + }; + switch (static_cast(id)) { + case ParamControl::kPlayMode: + play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate; + break; + case ParamControl::kPitchEngine: + play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed; + break; + case ParamControl::kAttack: play.adsr.attackFrames = normToFrames(value); break; + case ParamControl::kHold: play.adsr.holdFrames = normToFrames(value); break; + case ParamControl::kDecay: play.adsr.decayFrames = normToFrames(value); break; + case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break; + case ParamControl::kRelease: play.adsr.releaseFrames = normToFrames(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.attackFrames = normToFrames(value); break; + case ParamControl::kPitchEnvDecay: play.pitchEnv.decayFrames = normToFrames(value); break; + case ParamControl::kPitchEnvDepth: + play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis; + break; + default: break; + } +} + void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) { // Materialize the edited markers as a per-zone loop/start override on the picked id (upsert, // mirror of the root-marker path): a full-keyboard zone carrying the override. This plays @@ -492,6 +598,38 @@ Rect zonesStripArea(const EditorBands& bands) { 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. +Rect noteEntryFieldsArea(const EditorBands& bands) { + const Rect strip = Rect{bands.content.left + 8, + bands.content.top + 4 + 20 + 12 + kStripBandHeight, + bands.content.right - 8, 0}; + const int top = strip.top + 8; // legendTop (== zonesStripArea.bottom + 8) + return Rect{bands.content.left + 8 + 128, top, bands.content.right - 8, top + 18}; +} + +// The rect of note-entry field `f` (0=low, 1=high, 2=root) within the fields area: three equal +// segments left-to-right. An out-of-range index yields an empty rect. +Rect noteEntryFieldRect(const Rect& fields, int f) { + if (f < 0 || f > 2 || fields.width() <= 0) return Rect{}; + const int segW = fields.width() / 3; + const int left = fields.left + f * segW + (f > 0 ? 4 : 0); // small inter-field gap + const int right = (f == 2) ? fields.right : fields.left + (f + 1) * segW; + return Rect{left, fields.top, right, fields.bottom}; +} + +// The S12/S15/S16 parameter-control panel rect inside the Zones content: below the strip + +// the one-line selected-zone legend, running to the content bottom. `bands.content` is the +// Zones mode-content area. Both draw + hit-test use this single formula so they never drift. +Rect zonesControlPanel(const EditorBands& bands) { + constexpr int pad = 8; + const Rect strip = zonesStripArea(bands); + const int panelTop = strip.bottom + 8 + 18 + 8; // strip + the 18px legend row + gap + return Rect{bands.content.left + pad, panelTop, bands.content.right - pad, + bands.content.bottom - 4}; +} + // The S7 mono/stereo toggle, a two-segment control anchored to the RIGHT of the setup band's // header row (same y as the sample-name header, so it reads as "this capture's output mode"). // `area` is the full setup Rect. Returns {mono-segment, stereo-segment}; each is kSegW wide, @@ -572,13 +710,31 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) { const bool havePick = !selectedId_.empty(); const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight) : bands.content.bottom; - const Rect browserArea{bands.content.left, bands.content.top, bands.content.right, setupTop}; + const Rect fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop}; + + // S12: reserve a type-to-filter search box at the top of the browser area; the tabs + grid + // sit below it. The search box spans the browser width. + const Rect searchBox = searchBoxRect(fullBrowserArea.width()); + const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top, + fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom}; + LICE_FillRect(bmp, searchAbs.left, searchAbs.top, searchAbs.width(), searchAbs.height(), + searchFocused_ ? kColTabActiveBg : kColTabBg, 1.0f, 0); + { + std::string sb = searchQuery_.empty() + ? std::string("Search captures...") + : ("Search: " + searchQuery_ + (searchFocused_ ? "_" : "")); + Rect sbText{searchAbs.left + 6, searchAbs.top, searchAbs.right - 6, searchAbs.bottom}; + drawText(bmp, sbText, sb.c_str(), searchQuery_.empty() ? kRgbDim : kRgbText); + } + + const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop}; // The browser tabs + card grid, laid out by the pure module over the browser sub-area. // capture_browser lays out from (0,0); offset the draw by browserArea's origin. const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height()); const int ox = browserArea.left; const int oy = browserArea.top; + scrollOffset_ = clampScrollOffset(bl, static_cast(visible_.size()), scrollOffset_); // Filter tabs: an "All" tab (index 0) + one per named bank. The active tab highlights. const int tabCount = static_cast(banks_.size()) + 1; @@ -593,16 +749,24 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) { 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); - for (int i = 0; i < static_cast(visible_.size()); ++i) { + const int cardCount = static_cast(visible_.size()); + const VisibleRange vr = visibleCardRange(bl, cardCount, scrollOffset_); + for (int i = vr.first; i < vr.last; ++i) { + // The scrolled CELL, then the same gutter/thumbnail/label insets the pure module derives, + // shifted by the scroll offset (they share the cell's top, so subtract the offset). Rect content = cardContentRect(bl, i); - if (content.top + oy >= browserArea.bottom) break; // past the visible grid Rect thumb = cardThumbnailRect(bl, i); Rect labelR = cardLabelRect(bl, i); - content = Rect{content.left + ox, content.top + oy, content.right + ox, content.bottom + oy}; - thumb = Rect{thumb.left + ox, thumb.top + oy, thumb.right + ox, thumb.bottom + oy}; - labelR = Rect{labelR.left + ox, labelR.top + oy, labelR.right + ox, labelR.bottom + oy}; + content = Rect{content.left + ox, content.top + oy - scrollOffset_, + content.right + ox, content.bottom + oy - scrollOffset_}; + thumb = Rect{thumb.left + ox, thumb.top + oy - scrollOffset_, + thumb.right + ox, thumb.bottom + oy - scrollOffset_}; + labelR = Rect{labelR.left + ox, labelR.top + oy - scrollOffset_, + labelR.right + ox, labelR.bottom + oy - scrollOffset_}; const SampleChoice& s = visible_[static_cast(i)]; const bool sel = (s.id == selectedId_); @@ -624,6 +788,17 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) { drawText(bmp, badgeR, badge.c_str(), kRgbDim); } + // S12 scrollbar: a thumb in the grid's right-edge gutter, sized/positioned by the pure + // module (empty when the content fits — the shell simply draws nothing then). Offset by the + // browser origin like every other card rect. + { + const Rect thumb = scrollThumbRect(bl, cardCount, scrollOffset_); + if (thumb.height() > 0) { + LICE_FillRect(bmp, thumb.left + ox, thumb.top + oy, thumb.width(), thumb.height(), + kColRootMarker, 0.8f, 0); + } + } + if (havePick) { paintSetup(bmp, Rect{bands.content.left, setupTop, bands.content.right, bands.content.bottom}); } else if (visible_.empty()) { @@ -769,18 +944,100 @@ void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) { sel ? kColZoneBarSel : kColZoneBar, sel ? 1.0f : 0.7f, 0); } - // A one-line legend of the selected zone below the strip. - Rect infoR{stripArea.left, stripArea.bottom + 8, stripArea.right, stripArea.bottom + 26}; + // A one-line legend of the selected zone below the strip, with three click-to-type numeric + // entry fields (low / high / root) — S12 direct numeric entry. Clicking a field focuses it + // (entryField_) and typed text commits via parseNoteEntry on Enter. + const int legendTop = stripArea.bottom + 8; + Rect infoR{stripArea.left, legendTop, stripArea.right, legendTop + 18}; if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { const PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; - std::string info = sampleLabel(samples_, z.sampleId) + " " + noteLabel(z.lowNote) + - " - " + noteLabel(z.highNote) + " root " + - (z.rootOverride ? noteLabel(*z.rootOverride) + "*" : std::string("(bank)")); - drawText(bmp, infoR, info.c_str(), kRgbText); + drawText(bmp, Rect{infoR.left, infoR.top, infoR.left + 120, infoR.bottom}, + sampleLabel(samples_, z.sampleId).c_str(), kRgbText); + // Three fields laid out left-to-right after the sample label. + const Rect fields = noteEntryFieldsArea(bands); + const char* names[3] = {"Low", "High", "Root"}; + const std::string vals[3] = { + noteLabel(z.lowNote), noteLabel(z.highNote), + z.rootOverride ? noteLabel(*z.rootOverride) : std::string("(bank)")}; + for (int f = 0; f < 3; ++f) { + const Rect fr = noteEntryFieldRect(fields, f); + const bool editing = (entryField_ == f); + LICE_FillRect(bmp, fr.left, fr.top, fr.width(), fr.height(), + editing ? kColTabActiveBg : kColCardBg, 1.0f, 0); + LICE_DrawRect(bmp, fr.left, fr.top, fr.width() - 1, fr.height() - 1, + kColCardBorder, 1.0f, 0); + std::string cap = std::string(names[f]) + ": " + + (editing ? (entryText_ + "_") : vals[f]); + drawText(bmp, Rect{fr.left + 4, fr.top, fr.right - 2, fr.bottom}, cap.c_str(), kRgbText); + } } else if (map_.zones.empty()) { drawText(bmp, infoR, "No zones. Add Zone maps the picked capture across the keyboard.", kRgbDim); } + + // The S12/S15/S16 parameter surface for the selected zone (play mode + AHDSR / Trigger + + // pitch engine + AD pitch envelope). Only when a zone is selected. + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + 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) { + if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; + const ZonePlayParams play = map_.zones[static_cast(selectedZone_)].play; + const std::vector descs = controlDescs(play); + const std::vector rows = layoutControls(panel, descs); + + const auto labelsFor = [](ParamControl c) -> ControlLabels { + switch (c) { + case ParamControl::kPlayMode: return {"Mode", "Gate", "Trigger"}; + case ParamControl::kPitchEngine: return {"Pitch eng", "Varisp", "Preserve"}; + case ParamControl::kAttack: return {"Attack", "", ""}; + case ParamControl::kHold: return {"Hold", "", ""}; + case ParamControl::kDecay: return {"Decay", "", ""}; + case ParamControl::kSustain: return {"Sustain", "", ""}; + case ParamControl::kRelease: return {"Release", "", ""}; + case ParamControl::kTrigLength: return {"Length %", "", ""}; + case ParamControl::kTrigFadeIn: return {"Fade in", "", ""}; + case ParamControl::kTrigFadeOut: return {"Fade out", "", ""}; + case ParamControl::kPitchEnvEnable: return {"Pitch env", "Off", "On"}; + case ParamControl::kPitchEnvAttack: return {"P.Attack", "", ""}; + case ParamControl::kPitchEnvDecay: return {"P.Decay", "", ""}; + case ParamControl::kPitchEnvDepth: return {"P.Depth", "", ""}; + default: return {"", "", ""}; + } + }; + + for (const ControlRow& r : rows) { + if (r.row.top >= panel.bottom) break; // clip at the panel bottom + const ControlLabels lab = labelsFor(static_cast(r.id)); + drawText(bmp, r.label, lab.label, kRgbDim); + const double v = controlValue(r.id, play); + if (r.kind == ControlKind::Toggle) { + const bool seg1 = (v >= 0.5); + const Rect s0 = toggleSegmentRect(r.control, 0); + const Rect s1 = toggleSegmentRect(r.control, 1); + LICE_FillRect(bmp, s0.left, s0.top, s0.width(), s0.height(), + seg1 ? kColTabBg : kColTabActiveBg, 1.0f, 0); + LICE_FillRect(bmp, s1.left, s1.top, s1.width(), s1.height(), + seg1 ? kColTabActiveBg : kColTabBg, 1.0f, 0); + drawTextCentered(bmp, s0, lab.seg0, kRgbText); + drawTextCentered(bmp, s1, lab.seg1, kRgbText); + } else { + const Rect track = sliderTrackRect(r.control); + LICE_FillRect(bmp, track.left, track.top + track.height() / 2 - 1, track.width(), 2, + kColStripKey, 1.0f, 0); + const Rect handle = sliderHandleRect(r.control, v); + LICE_FillRect(bmp, handle.left, handle.top + 2, handle.width(), handle.height() - 4, + kColRootMarker, 1.0f, 0); + } + } } // --- Input: the drag-state machine ------------------------------------------- @@ -801,7 +1058,20 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { const bool havePick = !selectedId_.empty(); const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight) : bands.content.bottom; - const Rect browserArea{bands.content.left, bands.content.top, bands.content.right, setupTop}; + const Rect fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop}; + + // S12 search box (mirror of paintBrowser): a click focuses it; the browser sits below. + const Rect searchBox = searchBoxRect(fullBrowserArea.width()); + const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top, + fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom}; + if (contains(searchAbs, x, y)) { + searchFocused_ = true; + invalidate(); + return; + } + searchFocused_ = false; // any other browser click defocuses the search box + + const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop}; const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height()); const int bx = x - browserArea.left; const int by = y - browserArea.top; @@ -816,8 +1086,20 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { invalidate(); return; } - // Cards: pick a capture -> load it (this is the whole time-to-first-note gesture). - const int card = cardHitTest(bl, static_cast(visible_.size()), bx, by); + // S12 scrollbar thumb: grab to drag-scroll (checked before cards — the thumb overlays the + // grid's right gutter). scrollThumbRect is empty when the content fits, so this is inert then. + const Rect thumb = scrollThumbRect(bl, static_cast(visible_.size()), scrollOffset_); + if (thumb.height() > 0 && + contains(Rect{thumb.left + browserArea.left, thumb.top + browserArea.top, + thumb.right + browserArea.left, thumb.bottom + browserArea.top}, x, y)) { + drag_ = DragKind::kScrollThumb; + dragStartY_ = y; + dragStartScrollOffset_ = scrollOffset_; + return; + } + // Cards: pick a capture -> load it (this is the whole time-to-first-note gesture). The + // hit-test adds the scroll offset back so a scrolled card maps to the right index. + const int card = cardHitTest(bl, static_cast(visible_.size()), bx, by + scrollOffset_); if (card >= 0) { selectedId_ = visible_[static_cast(card)].id; commitAndReload(); // publishes the pick + reloads; process() plays it repitched @@ -958,6 +1240,54 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { map_.zones[static_cast(selectedZone_)].rootOverride = note; commitAndReload(); } + return; + } + + // S12 numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a + // zone is selected. entryText_ starts empty (the user types the full value). + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + const Rect fields = noteEntryFieldsArea(bands); + for (int f = 0; f < 3; ++f) { + if (contains(noteEntryFieldRect(fields, f), x, y)) { + entryField_ = f; + entryText_.clear(); + invalidate(); + return; + } + } + } + entryField_ = -1; // a click elsewhere in the Zones view cancels an in-progress entry + + // The S12/S15/S16 parameter panel: a toggle segment flips at once (commit); a slider grab + // starts a live drag (commit on release). Only when a zone is selected. + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + const Rect panel = zonesControlPanel(bands); + const std::vector descs = controlDescs(z.play); + const std::vector rows = layoutControls(panel, descs); + const int id = controlAtPoint(rows, x, y); + if (id >= 0) { + // Find the row to know its kind + control rect. + for (const ControlRow& r : rows) { + if (r.id != id) continue; + if (r.kind == ControlKind::Toggle) { + const int seg = toggleSegmentHitTest(r.control, x, y); + if (seg >= 0) { + applyControl(id, z.play, 0.0, seg); + commitAndReload(); // a toggle is a discrete, final edit + } + } else { + // Grab the slider: set the value at the grab x immediately, then live-drag. + drag_ = DragKind::kParamSlider; + dragParamId_ = id; + dragParamPanel_ = panel; + dragStartMap_ = map_; + applyControl(id, z.play, valueAtPoint(r.control, x), 0); + invalidate(); // live feedback; commit on WM_LBUTTONUP + } + break; + } + } } } @@ -1044,6 +1374,40 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { return; } + if (drag_ == DragKind::kScrollThumb) { + // S12: map the thumb-drag pixel delta to a new (clamped) scroll offset. The visible-card + // window recomputes at paint from scrollOffset_. The browser sub-area matches paintBrowser + // when a capture is picked (the setup band takes the bottom). + const int dyThumb = y - dragStartY_; + const bool havePick = !selectedId_.empty(); + const int setupTop = havePick + ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight) + : bands.content.bottom; + const BrowserLayout bl = layoutBrowser(bands.content.width(), setupTop - bands.content.top); + scrollOffset_ = thumbDragToOffset(bl, static_cast(visible_.size()), + dragStartScrollOffset_, dyThumb); + invalidate(); + return; + } + + if (drag_ == DragKind::kParamSlider) { + // S12/S15/S16: re-lay the panel and map x -> value against the grabbed control's live + // track rect (the panel geometry is stable during the drag; re-laying keeps the value + // mapping exact even if a mode toggle changed the row set — it did not, mid-drag). + if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; + PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + const std::vector descs = controlDescs(z.play); + const std::vector rows = layoutControls(dragParamPanel_, descs); + for (const ControlRow& r : rows) { + if (r.id == dragParamId_) { + applyControl(dragParamId_, z.play, valueAtPoint(r.control, x), 0); + break; + } + } + invalidate(); + return; + } + // Zone edits: recompute the grabbed field(s) against the pure resolver, live. if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; const Rect stripArea = zonesStripArea(bands); @@ -1068,11 +1432,79 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { void ReaSamplerEditor::onMouseUp(int /*x*/, int /*y*/) { if (drag_ == DragKind::kNone) return; + const DragKind kind = drag_; 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(); } +void ReaSamplerEditor::onMouseWheel(int delta) { + // S12 browser scroll (only in the browser view). One wheel notch (WHEEL_DELTA==120) scrolls + // roughly one card row; the offset is clamped at paint (the layout/panel height is known + // there). A positive delta (wheel up) scrolls toward the top (smaller offset). + if (view_ != View::kBrowser) return; + const int rows = delta / 120; + if (rows == 0) return; + scrollOffset_ -= rows * kBrowserCardHeight; + if (scrollOffset_ < 0) scrollOffset_ = 0; // paint clamps the upper bound to the content + invalidate(); +} + +void ReaSamplerEditor::onSearchChar(unsigned int ch) { + // S12 numeric note-entry (Zones view): a focused low/high/root field accumulates keystrokes + // and commits via parseNoteEntry on Enter. Handled before the search box (a field, when + // focused, owns the keystrokes). + if (view_ == View::kZones && entryField_ >= 0) { + if (ch == 13) { // Enter: parse + commit + if (auto note = parseNoteEntry(entryText_)) { + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + if (entryField_ == 0) z.lowNote = (std::min)(*note, z.highNote); + else if (entryField_ == 1) z.highNote = (std::max)(*note, z.lowNote); + else z.rootOverride = *note; + commitAndReload(); + } + } + entryField_ = -1; + entryText_.clear(); + invalidate(); + } else if (ch == 27) { // Escape cancels + entryField_ = -1; + entryText_.clear(); + invalidate(); + } else if (ch == 8) { // backspace + if (!entryText_.empty()) entryText_.pop_back(); + invalidate(); + } else if (ch >= 32 && ch < 127) { + entryText_.push_back(static_cast(ch)); + invalidate(); + } + return; + } + + // S12 type-to-filter search. Only when the search box has focus (a click focuses it). Backspace + // deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then search). + if (view_ != View::kBrowser || !searchFocused_) return; + if (ch == 8) { // backspace + if (!searchQuery_.empty()) searchQuery_.pop_back(); + } else if (ch == 27) { // escape clears + defocuses + searchQuery_.clear(); + searchFocused_ = false; + } else if (ch >= 32 && ch < 127) { + searchQuery_.push_back(static_cast(ch)); + } else { + return; // ignore other control chars + } + scrollOffset_ = 0; // a new filter resets the scroll to the top of the narrowed list + rebuildVisible(); + invalidate(); +} + LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { auto* self = @@ -1088,12 +1520,24 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, case WM_LBUTTONDOWN: if (self) { SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child + SetFocus(hwnd); // take keyboard focus so WM_CHAR reaches the search box (S12) self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); } return 0; case WM_MOUSEMOVE: if (self) self->onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); return 0; + case WM_MOUSEWHEEL: + // S12 browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up. + if (self) self->onMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam)); + return 0; + case WM_CHAR: + // S12 type-to-filter search keystrokes (only acted on when the search box is focused). + if (self) self->onSearchChar(static_cast(wParam)); + return 0; + case WM_GETDLGCODE: + // Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (S12 search). + return DLGC_WANTCHARS | DLGC_WANTARROWS; case WM_LBUTTONUP: if (self) { self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); @@ -1106,7 +1550,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. // Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler. 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->invalidate(); } diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index 6959bd3..d8af4af 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -31,6 +31,7 @@ #include "public.sdk/source/common/pluginview.h" #include "editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules) +#include "param_slider.h" // ControlRow (the S12/S15/S16 control-surface geometry) #include "peaks.h" // Envelope (the cached peak thumbnail) #include "sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot) @@ -70,7 +71,29 @@ private: // flight. The zone-edit grabs mirror keyboard_strip::ZoneGrab; kRootMarker is the // single-capture root drag on the setup strip; kWaveMarker is a draggable start/loop // marker on the S11 waveform surface (which marker is in waveMarker_). - enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker }; + enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker, + kScrollThumb, kParamSlider }; + + // The parameter controls on the setup surface (S12 AHDSR + the S15/S16 control surfaces). + // The int value is the ControlDesc id the pure param_slider hit-test returns; the shell + // maps it to the picked zone's play params. Order here is the panel's top-down stack order. + enum class ParamControl { + kPlayMode = 0, // Gate | Trigger toggle (S15) + kPitchEngine, // Varispeed | Preserve toggle (S16) + kAttack, // AHDSR attack (Gate) / — + kHold, // AHDSR hold (Gate, S15) + kDecay, // AHDSR decay (Gate) + kSustain, // AHDSR sustain (Gate) + kRelease, // AHDSR release (Gate) + kTrigLength, // Trigger %-length (Trigger, S15) + kTrigFadeIn, // Trigger fade-in (Trigger, S15) + kTrigFadeOut, // Trigger fade-out (Trigger, S15) + kPitchEnvEnable, // AD pitch envelope on|off (S16) + kPitchEnvAttack, // AD pitch attack (S16) + kPitchEnvDecay, // AD pitch decay (S16) + kPitchEnvDepth, // AD pitch depth in +/- semitones (S16) + kCount + }; // The waveform markers on the single-capture setup surface (S11). Order is the draw + hit // order (start first). Named generically per the spec so S15 can repurpose the surface with @@ -83,10 +106,13 @@ private: void paintSetup(LICE_IBitmap* bmp, const Rect& area); void paintZones(LICE_IBitmap* bmp, int w, int h); void paintEmptyState(LICE_IBitmap* bmp, const Rect& area); + void paintControls(LICE_IBitmap* bmp, const Rect& panel); // S12/S15/S16 param surface void onMouseDown(int x, int y); void onMouseMove(int x, int y); void onMouseUp(int x, int y); + void onMouseWheel(int delta); // S12 browser scroll (wheel) + void onSearchChar(unsigned int ch); // S12 type-to-filter search keystroke // 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; @@ -149,6 +175,25 @@ private: // final commit. selectedId_ must be non-empty before calling. void upsertPickedOverride(const SetupMarkers& m); + // --- S12/S15/S16 parameter surface (Zones panel, keyed to selectedZone_) ------ + // + // The control panel edits the SELECTED zone's ZonePlayParams (S15 play mode + AHDSR; S16 + // pitch engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact. + + // The control descriptors the panel shows for `play`'s CURRENT play mode: the two toggles + + // the mode-relevant sliders (AHDSR for Gate, %-length/fades for Trigger) + the pitch-envelope + // controls. The pure param_slider lays these out; this only picks the set. Static (a free + // choice of set from the mode) — kept a member for the ParamControl enum access. + std::vector controlDescs(const ZonePlayParams& play) const; + + // The normalized [0,1] display value for control `id` given `play` (the shell's domain + // mapping: frames->0..1 over a fixed max, sustain 0..1 as-is, semitone depth centered at 0.5). + double controlValue(int id, const ZonePlayParams& play) const; + + // Apply a committed control interaction to `play`: a slider's normalized `value` (mapped back + // into the control's engine domain) or a toggle's `segment` (0/1). Mutates `play` in place. + void applyControl(int id, ZonePlayParams& play, double value, int segment) const; + ReaSamplerProcessor* processor_ = nullptr; // --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) --- @@ -164,9 +209,22 @@ private: std::string activeFilterBankId_; // "" = All; else a bank id from banks_ 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 ------------------------------------------------------ DragKind drag_ = DragKind::kNone; int dragStartX_ = 0; // grab x (px), for the pixel-delta resolver + int dragStartY_ = 0; // grab y (px), for the vertical scrollbar-thumb drag int dragStartLow_ = 0; // the grabbed field's note at grab time int dragStartHigh_ = 0; int dragStartRoot_ = 60; @@ -179,6 +237,13 @@ private: SetupMarkers dragStartMarkers_; std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the drag + // S12 scrollbar-thumb drag: the offset held at grab time (the pixel-delta resolver shifts + // from it). S12/S15/S16 param-slider drag: which control id + the panel it lives in (the + // shell re-lays the panel each move to map x->value against the live control rect). + int dragStartScrollOffset_ = 0; + int dragParamId_ = -1; + Rect dragParamPanel_{}; + // --- Peak-thumbnail cache (mirror of bank_panel; id -> envelope at a bin width) ------ // Keyed by "id|binCount" so a resize recomputes at the new width. Cleared on refresh so // a bank edit (a re-captured or deleted sample) does not show a stale thumbnail. diff --git a/tests/test_browser_scroll.cpp b/tests/test_browser_scroll.cpp new file mode 100644 index 0000000..9448f52 --- /dev/null +++ b/tests/test_browser_scroll.cpp @@ -0,0 +1,179 @@ +// Standalone tests for reasampler::vst::browser_scroll — no VST3, no REAPER, no framework. +// Same fast assert loop as the sibling pure editor tests: assert the S12 scroll-window + +// scrollbar-thumb + type-to-filter-search geometry LAYERED over the S10 capture_browser. +// +// Covers: scrollContentHeight (ceil rows * card height, 0 for no cards); scrollMaxOffset (0 +// when content fits, else content-visible); clampScrollOffset pinning to [0,max]; visibleCardRange +// windowing (top rows only, scrolled window, empty when scrolled past the end); scrolledCardCellRect +// shifting a cell up by the offset; scrollThumbRect (empty when it fits, proportional height + +// position, minimum height, at-max pins to the track bottom); thumbDragToOffset as the position +// inverse (a full-track drag reaches max, round-trips); searchBoxRect; nameMatchesQuery +// (case-insensitive substring, empty-query identity, no-match); filterNameIndices preserving order +// and returning every index for an empty query. + +#include "../src/vst/browser_scroll.h" + +#include +#include +#include + +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// A layout wide enough for a few columns and tall enough to show a few rows. +static BrowserLayout wideLayout() { return layoutBrowser(560, 300); } + +// --- content / max / clamp ---------------------------------------------------- + +static void testContentHeight() { + const BrowserLayout L = wideLayout(); + CHECK(scrollContentHeight(L, 0) == 0); + // One card -> one row -> one card height. + CHECK(scrollContentHeight(L, 1) == kBrowserCardHeight); + // columns+1 cards -> two rows. + CHECK(scrollContentHeight(L, L.columns + 1) == 2 * kBrowserCardHeight); + // Exactly `columns` cards -> one row. + CHECK(scrollContentHeight(L, L.columns) == kBrowserCardHeight); +} + +static void testMaxOffsetFitsAndOverflows() { + const BrowserLayout L = wideLayout(); + // A single row fits within the 300px area -> no scroll. + CHECK(scrollMaxOffset(L, L.columns) == 0); + // Many rows overflow -> max = content - gridHeight. + const int many = L.columns * 20; + const int expect = scrollContentHeight(L, many) - L.grid.height(); + CHECK(scrollMaxOffset(L, many) == expect); + CHECK(expect > 0); +} + +static void testClamp() { + const BrowserLayout L = wideLayout(); + const int many = L.columns * 20; + const int maxOff = scrollMaxOffset(L, many); + CHECK(clampScrollOffset(L, many, -50) == 0); + CHECK(clampScrollOffset(L, many, maxOff + 500) == maxOff); + CHECK(clampScrollOffset(L, many, maxOff / 2) == maxOff / 2); +} + +// --- visible window ----------------------------------------------------------- + +static void testVisibleRangeTop() { + const BrowserLayout L = wideLayout(); + const int many = L.columns * 20; + const VisibleRange vr = visibleCardRange(L, many, 0); + CHECK(vr.first == 0); + // At offset 0, the last visible row is the one containing (gridH-1). + const int expectedLastRow = (L.grid.height() - 1) / kBrowserCardHeight + 1; + CHECK(vr.last == expectedLastRow * L.columns); +} + +static void testVisibleRangeScrolled() { + const BrowserLayout L = wideLayout(); + const int many = L.columns * 20; + // Scroll one full card row down. + const VisibleRange vr = visibleCardRange(L, many, kBrowserCardHeight); + CHECK(vr.first == L.columns); // the first row scrolled off the top +} + +static void testVisibleRangeEmptyWhenNoCards() { + const BrowserLayout L = wideLayout(); + const VisibleRange vr = visibleCardRange(L, 0, 0); + CHECK(vr.first == 0 && vr.last == 0); +} + +static void testScrolledCellShiftsUp() { + const BrowserLayout L = wideLayout(); + const Rect base = cardCellRect(L, 3); + const Rect shifted = scrolledCardCellRect(L, 3, 40); + CHECK(shifted.top == base.top - 40); + CHECK(shifted.bottom == base.bottom - 40); + CHECK(shifted.left == base.left); +} + +// --- scrollbar thumb ---------------------------------------------------------- + +static void testThumbEmptyWhenFits() { + const BrowserLayout L = wideLayout(); + CHECK(scrollThumbRect(L, L.columns, 0).height() == 0); // one row fits -> no thumb +} + +static void testThumbProportionalAndClamped() { + const BrowserLayout L = wideLayout(); + const int many = L.columns * 20; + const Rect atTop = scrollThumbRect(L, many, 0); + CHECK(atTop.height() > 0); + CHECK(atTop.top == L.grid.top); // at offset 0 the thumb starts at the track top + CHECK(atTop.width() == kScrollbarWidth); + CHECK(atTop.right == L.grid.right); + // At max offset, the thumb bottom reaches the grid bottom (pinned to the end). + const int maxOff = scrollMaxOffset(L, many); + const Rect atMax = scrollThumbRect(L, many, maxOff); + CHECK(atMax.bottom == L.grid.top + L.grid.height()); +} + +static void testThumbDragIsInverse() { + const BrowserLayout L = wideLayout(); + const int many = L.columns * 20; + const int maxOff = scrollMaxOffset(L, many); + // A zero drag holds the start offset. + CHECK(thumbDragToOffset(L, many, 0, 0) == 0); + // A large positive drag pins to max; a large negative drag pins to 0. + CHECK(thumbDragToOffset(L, many, 0, 100000) == maxOff); + CHECK(thumbDragToOffset(L, many, maxOff, -100000) == 0); + // Dragging the thumb by the whole track span from top reaches (near) max. + const Rect thumb = scrollThumbRect(L, many, 0); + const int trackSpan = L.grid.height() - thumb.height(); + const int off = thumbDragToOffset(L, many, 0, trackSpan); + CHECK(off >= maxOff - 2 && off <= maxOff); +} + +// --- search ------------------------------------------------------------------- + +static void testSearchBoxRect() { + const Rect r = searchBoxRect(200); + CHECK(r.left == 0 && r.top == 0 && r.right == 200 && r.height() == kSearchBoxHeight); + CHECK(searchBoxRect(0).width() == 0); +} + +static void testNameMatch() { + CHECK(nameMatchesQuery("Kick Drum 01", "")); // empty query matches all + CHECK(nameMatchesQuery("Kick Drum 01", "drum")); // case-insensitive substring + CHECK(nameMatchesQuery("Kick Drum 01", "KICK")); + CHECK(!nameMatchesQuery("Kick Drum 01", "snare")); + CHECK(!nameMatchesQuery("ab", "abc")); // query longer than name +} + +static void testFilterIndices() { + std::vector names{"Kick", "Snare", "Kick Sub", "Hat"}; + // Empty query -> every index, in order. + const std::vector all = filterNameIndices(names, ""); + CHECK(all.size() == 4 && all[0] == 0 && all[3] == 3); + // "kick" -> indices 0 and 2, order preserved. + const std::vector kicks = filterNameIndices(names, "kick"); + CHECK(kicks.size() == 2 && kicks[0] == 0 && kicks[1] == 2); + // No match -> empty. + CHECK(filterNameIndices(names, "zzz").empty()); +} + +int main() { + testContentHeight(); + testMaxOffsetFitsAndOverflows(); + testClamp(); + testVisibleRangeTop(); + testVisibleRangeScrolled(); + testVisibleRangeEmptyWhenNoCards(); + testScrolledCellShiftsUp(); + testThumbEmptyWhenFits(); + testThumbProportionalAndClamped(); + testThumbDragIsInverse(); + testSearchBoxRect(); + testNameMatch(); + testFilterIndices(); + + if (g_fail == 0) std::printf("browser_scroll: all tests passed\n"); + return g_fail != 0; +} diff --git a/tests/test_note_entry.cpp b/tests/test_note_entry.cpp new file mode 100644 index 0000000..4990001 --- /dev/null +++ b/tests/test_note_entry.cpp @@ -0,0 +1,73 @@ +// Standalone tests for reasampler::vst::note_entry — no VST3, no REAPER, no framework. +// Assert the S12 direct-numeric-entry parse for a zone's low/high/root MIDI note. +// +// Covers: plain decimal integers (with +/- sign + surrounding whitespace); note names under the +// C4==60 convention (C-1==0, sharps + flats, negative octaves); out-of-range values CLAMPING to +// [0,127] rather than rejecting; empty / whitespace-only / unparseable input returning nullopt; +// the integer path taking precedence over the note-name path for a leading digit. + +#include "../src/vst/note_entry.h" + +#include + +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static void testPlainIntegers() { + CHECK(parseNoteEntry("60") == 60); + CHECK(parseNoteEntry("0") == 0); + CHECK(parseNoteEntry("127") == 127); + CHECK(parseNoteEntry(" 64 ") == 64); // surrounding whitespace ignored + CHECK(parseNoteEntry("+5") == 5); +} + +static void testIntegerClamps() { + CHECK(parseNoteEntry("200") == 127); // over-range clamps to the ceiling + CHECK(parseNoteEntry("-10") == 0); // under-range clamps to the floor + CHECK(parseNoteEntry("99999") == 127); +} + +static void testNoteNames() { + // C4 == 60 (MIDI 0 == C-1). + CHECK(parseNoteEntry("C4") == 60); + CHECK(parseNoteEntry("c4") == 60); // case-insensitive + CHECK(parseNoteEntry("A4") == 69); // A4 = 69 (concert A) + CHECK(parseNoteEntry("C-1") == 0); // lowest MIDI note + CHECK(parseNoteEntry("G9") == 127); // G9 = 127 +} + +static void testAccidentals() { + CHECK(parseNoteEntry("C#4") == 61); + CHECK(parseNoteEntry("Db4") == 61); // enharmonic of C#4 + CHECK(parseNoteEntry("F#3") == 54); + CHECK(parseNoteEntry("Bb3") == 58); // Bb3 = 58 +} + +static void testNoteNameClamps() { + CHECK(parseNoteEntry("C10") == 127); // above the range clamps + CHECK(parseNoteEntry("C-5") == 0); // below the range clamps +} + +static void testRejects() { + CHECK(parseNoteEntry("") == std::nullopt); + CHECK(parseNoteEntry(" ") == std::nullopt); + CHECK(parseNoteEntry("hello") == std::nullopt); + CHECK(parseNoteEntry("C") == std::nullopt); // a bare letter with no octave is ambiguous + CHECK(parseNoteEntry("H4") == std::nullopt); // H is not a note letter + CHECK(parseNoteEntry("+") == std::nullopt); +} + +int main() { + testPlainIntegers(); + testIntegerClamps(); + testNoteNames(); + testAccidentals(); + testNoteNameClamps(); + testRejects(); + + if (g_fail == 0) std::printf("note_entry: all tests passed\n"); + return g_fail != 0; +} diff --git a/tests/test_param_slider.cpp b/tests/test_param_slider.cpp new file mode 100644 index 0000000..7887c9d --- /dev/null +++ b/tests/test_param_slider.cpp @@ -0,0 +1,204 @@ +// Standalone tests for reasampler::vst::param_slider — no VST3, no REAPER, no framework. +// Same fast assert loop as the sibling pure editor tests (capture_browser / keyboard_strip): +// assert the S12/S15/S16 control-surface layout, toggle-segment split + hit-test, slider +// value<->pixel mapping (round-trip + clamping + endpoints), and point->control routing. +// +// Covers: layoutControls stacking rows top-down with the label column + control column and the +// inter-row gap; an empty list / degenerate panel yielding nothing; toggleSegmentRect splitting +// a toggle into two tiling segments (last absorbs the remainder) + toggleSegmentHitTest; +// sliderTrackRect insetting a half-handle at each end; sliderHandleRect at value 0/0.5/1 and +// out-of-range clamping; valueAtPoint mapping x back to 0..1 (endpoints saturate) as the inverse +// of the handle position; controlAtPoint routing a point to the right control id (toggle whole +// area vs slider track) and MISSING in the label column, a row gap, and off-panel. + +#include "../src/vst/param_slider.h" + +#include +#include + +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static bool approx(double a, double b) { return (a - b) < 1e-9 && (b - a) < 1e-9; } + +// --- layoutControls ----------------------------------------------------------- + +static void testLayoutStacksRows() { + const Rect panel{0, 100, 300, 400}; + std::vector ctl{ + {1, ControlKind::Toggle}, + {2, ControlKind::Slider}, + {3, ControlKind::Slider}, + }; + const std::vector rows = layoutControls(panel, ctl); + CHECK(rows.size() == 3); + // Row 0 sits at the panel top; each subsequent row is one row-height + gap below. + CHECK(rows[0].row.top == 100); + CHECK(rows[0].row.bottom == 100 + kControlRowHeight); + CHECK(rows[1].row.top == rows[0].row.bottom + kControlRowGap); + CHECK(rows[2].row.top == rows[1].row.bottom + kControlRowGap); + // Ids + kinds carried through in order. + CHECK(rows[0].id == 1 && rows[0].kind == ControlKind::Toggle); + CHECK(rows[1].id == 2 && rows[1].kind == ControlKind::Slider); + // Label column then control column, contiguous, spanning the panel width. + CHECK(rows[0].label.left == panel.left); + CHECK(rows[0].control.left == rows[0].label.right); + CHECK(rows[0].control.right == panel.right); + CHECK(rows[0].label.width() == kControlLabelWidth); +} + +static void testLayoutEmptyAndDegenerate() { + CHECK(layoutControls(Rect{0, 0, 300, 300}, {}).empty()); + std::vector ctl{{1, ControlKind::Slider}}; + CHECK(layoutControls(Rect{0, 0, 0, 0}, ctl).empty()); + CHECK(layoutControls(Rect{0, 0, 300, 0}, ctl).empty()); +} + +static void testLayoutNarrowPanelClampsLabel() { + // A panel narrower than 2*labelWidth clamps the label column to half so a control column + // survives. + const Rect panel{0, 0, 100, 200}; + const std::vector rows = layoutControls(panel, {{1, ControlKind::Slider}}); + CHECK(rows.size() == 1); + CHECK(rows[0].label.width() <= panel.width() / 2 + 1); + CHECK(rows[0].control.width() > 0); +} + +// --- toggle ------------------------------------------------------------------- + +static void testToggleSegmentsTile() { + const Rect control{100, 0, 300, 22}; // width 200 + const Rect s0 = toggleSegmentRect(control, 0); + const Rect s1 = toggleSegmentRect(control, 1); + CHECK(s0.left == 100 && s0.right == 200); + CHECK(s1.left == 200 && s1.right == 300); // last absorbs remainder -> reaches control.right + // Out of range. + CHECK(toggleSegmentRect(control, 2).width() == 0); + CHECK(toggleSegmentRect(control, -1).width() == 0); +} + +static void testToggleSegmentRemainderInLast() { + const Rect control{0, 0, 201, 22}; // odd width -> seg0 = 100, seg1 = 101 (absorbs remainder) + CHECK(toggleSegmentRect(control, 0).width() == 100); + CHECK(toggleSegmentRect(control, 1).right == 201); +} + +static void testToggleHitTest() { + const Rect control{100, 0, 300, 22}; + CHECK(toggleSegmentHitTest(control, 150, 10) == 0); + CHECK(toggleSegmentHitTest(control, 250, 10) == 1); + CHECK(toggleSegmentHitTest(control, 50, 10) == -1); // left of control + CHECK(toggleSegmentHitTest(control, 150, 40) == -1); // below control +} + +// --- slider ------------------------------------------------------------------- + +static void testSliderTrackInsetsHalfHandle() { + const Rect control{100, 0, 300, 22}; + const Rect track = sliderTrackRect(control); + CHECK(track.left == control.left + kSliderHandleWidth / 2); + CHECK(track.right == control.right - kSliderHandleWidth / 2); + // A control too narrow for a handle yields an empty track. + CHECK(sliderTrackRect(Rect{0, 0, kSliderHandleWidth - 1, 22}).width() == 0); +} + +static void testSliderHandleAtEndpointsAndMid() { + const Rect control{100, 0, 300, 22}; + const Rect track = sliderTrackRect(control); + const int half = kSliderHandleWidth / 2; + // Value 0 -> handle centered at track.left. + const Rect h0 = sliderHandleRect(control, 0.0); + CHECK(h0.left + half == track.left); + // Value 1 -> handle centered at track.right. + const Rect h1 = sliderHandleRect(control, 1.0); + CHECK(h1.left + half == track.right); + // Value 0.5 -> centered at the track middle. + const Rect hm = sliderHandleRect(control, 0.5); + CHECK(hm.left + half == track.left + track.width() / 2); +} + +static void testSliderHandleClampsOutOfRange() { + const Rect control{0, 0, 200, 22}; + CHECK(sliderHandleRect(control, -0.5).left == sliderHandleRect(control, 0.0).left); + CHECK(sliderHandleRect(control, 5.0).left == sliderHandleRect(control, 1.0).left); +} + +static void testValueAtPointEndpointsSaturate() { + const Rect control{100, 0, 300, 22}; + const Rect track = sliderTrackRect(control); + CHECK(approx(valueAtPoint(control, track.left - 20), 0.0)); + CHECK(approx(valueAtPoint(control, track.left), 0.0)); + CHECK(approx(valueAtPoint(control, track.right + 20), 1.0)); + CHECK(approx(valueAtPoint(control, track.right), 1.0)); +} + +static void testValueAtPointIsHandleInverse() { + // Round-trip: a value -> handle center -> valueAtPoint recovers (within one pixel quantum). + const Rect control{50, 0, 450, 22}; // wide track for pixel resolution + const Rect track = sliderTrackRect(control); + for (double v : {0.1, 0.25, 0.5, 0.75, 0.9}) { + const Rect h = sliderHandleRect(control, v); + const int centerX = h.left + kSliderHandleWidth / 2; + const double back = valueAtPoint(control, centerX); + CHECK(back >= v - 0.01 && back <= v + 0.01); + CHECK(centerX >= track.left && centerX <= track.right); + } +} + +static void testValueAtPointDegenerateTrack() { + CHECK(approx(valueAtPoint(Rect{0, 0, kSliderHandleWidth - 1, 22}, 5), 0.0)); +} + +// --- controlAtPoint routing --------------------------------------------------- + +static void testControlAtPointRoutes() { + const Rect panel{0, 0, 300, 400}; + std::vector ctl{ + {10, ControlKind::Toggle}, + {20, ControlKind::Slider}, + }; + const std::vector rows = layoutControls(panel, ctl); + // A point in the toggle's control area routes to the toggle id. + const Rect tctl = rows[0].control; + CHECK(controlAtPoint(rows, (tctl.left + tctl.right) / 2, (tctl.top + tctl.bottom) / 2) == 10); + // A point on the slider's track routes to the slider id. + const Rect strack = sliderTrackRect(rows[1].control); + CHECK(controlAtPoint(rows, (strack.left + strack.right) / 2, + (strack.top + strack.bottom) / 2) == 20); +} + +static void testControlAtPointMisses() { + const Rect panel{0, 0, 300, 400}; + const std::vector rows = + layoutControls(panel, {{10, ControlKind::Toggle}, {20, ControlKind::Slider}}); + // The label column is not interactive. + CHECK(controlAtPoint(rows, rows[0].label.left + 2, rows[0].label.top + 4) == -1); + // The gap between rows is a miss. + const int gapY = rows[0].row.bottom + kControlRowGap / 2; + CHECK(controlAtPoint(rows, 200, gapY) == -1); + // Off-panel below. + CHECK(controlAtPoint(rows, 200, 5000) == -1); +} + +int main() { + testLayoutStacksRows(); + testLayoutEmptyAndDegenerate(); + testLayoutNarrowPanelClampsLabel(); + testToggleSegmentsTile(); + testToggleSegmentRemainderInLast(); + testToggleHitTest(); + testSliderTrackInsetsHalfHandle(); + testSliderHandleAtEndpointsAndMid(); + testSliderHandleClampsOutOfRange(); + testValueAtPointEndpointsSaturate(); + testValueAtPointIsHandleInverse(); + testValueAtPointDegenerateTrack(); + testControlAtPointRoutes(); + testControlAtPointMisses(); + + if (g_fail == 0) std::printf("param_slider: all tests passed\n"); + return g_fail != 0; +} From 7fb778206c7c067858f4e0e0a72c828db0c7436d Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 01:38:48 -0400 Subject: [PATCH 2/6] fix(S12 review): per-zone A/D/S/R reaches voice; zones payload v4 + v3 back-compat Voice::start reads full zone ADSR instead of folding only holdFrames into instrument-wide gateAdsr. Wire format bumped to v4 (four new fields); v3 blobs lift A/D/S/R to kTier0Nominal* constants. Single-capture editor controls now reachable. Minor comment and geometry fixes. --- src/vst/reasampler_editor.cpp | 67 +++++++++++++++++++++++------- src/vst/sample_map.cpp | 27 +++++++++++- src/vst/sample_map.h | 58 +++++++++++++++++++------- src/vst/sampler_core.cpp | 19 ++++++--- tests/test_sample_map.cpp | 60 +++++++++++++++++++++++++++ tests/test_sampler_core.cpp | 77 +++++++++++++++++++++++++++++++---- 6 files changed, 266 insertions(+), 42 deletions(-) diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index f0e7df8..9d2f7e6 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -299,10 +299,13 @@ void ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) { namespace { // The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is -// engine-free and maps only 0..1). Time sliders span [0, max] frames at a nominal rate so a -// full-throw reaches a musically generous ceiling; the exact wall-clock is DAW-verified. These -// are build-time residuals (one place to retune), not persisted. -constexpr double kEnvTimeMaxFrames = 2.0 * 44100.0; // AHDSR A/H/D/R + pitch A/D throw ceiling +// engine-free and maps only 0..1). Time sliders span [0, max] frames at the NOMINAL 44100 Hz +// rate; the stored frame count is host-rate-independent, so at other DAW rates the same slider +// position maps to a slightly different wall-clock duration. The ceiling is kept nominal-only +// because the host rate is not reachable inside the editor without a processor callback, and the +// approximation is musically negligible (±1-2 ms at typical rates). Build-time residual — one +// place to retune; not persisted. +constexpr double kEnvTimeMaxFrames = 2.0 * 44100.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (44100 nominal) 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); } @@ -600,12 +603,11 @@ Rect zonesStripArea(const EditorBands& bands) { // 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. +// 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 Rect strip = Rect{bands.content.left + 8, - bands.content.top + 4 + 20 + 12 + kStripBandHeight, - bands.content.right - 8, 0}; - const int top = strip.top + 8; // legendTop (== zonesStripArea.bottom + 8) + 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}; } @@ -976,8 +978,13 @@ void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) { } // The S12/S15/S16 parameter surface for the selected zone (play mode + AHDSR / Trigger + - // pitch engine + AD pitch envelope). Only when a zone is selected. - if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + // pitch engine + AD pitch envelope). Shown for an explicit zone selection OR for the + // single-capture face when the map is empty but a capture is picked (S15-F2 lean: the + // single capture is already a one-zone map — one storage site serves both). + const bool haveControlTarget = + (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) || + (map_.zones.empty() && !selectedId_.empty()); + if (haveControlTarget) { paintControls(bmp, zonesControlPanel(computeBands(w, h))); } } @@ -989,8 +996,18 @@ struct ControlLabels { const char* label; const char* seg0; const char* seg1; }; } // namespace void ReaSamplerEditor::paintControls(LICE_IBitmap* bmp, const Rect& panel) { - if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; - const ZonePlayParams play = map_.zones[static_cast(selectedZone_)].play; + // 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). + ZonePlayParams play; + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + play = map_.zones[static_cast(selectedZone_)].play; + } else if (map_.zones.empty() && !selectedId_.empty()) { + play = PerformanceZone{}.play; // product defaults (Gate + Preserve + tier-0 ADSR) + } else { + return; // no control target + } const std::vector descs = controlDescs(play); const std::vector rows = layoutControls(panel, descs); @@ -1259,7 +1276,29 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { 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). Only when a zone is selected. + // 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 ZonePlayParams defaultPlay = PerformanceZone{}.play; + const std::vector probeDescs = controlDescs(defaultPlay); + const std::vector probeRows = layoutControls(panel, probeDescs); + if (controlAtPoint(probeRows, x, y) >= 0) { + // The click lands in the control panel — materialize the zone now. + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + map_.zones.push_back(z); + selectedZone_ = 0; + // Fall through to the control handler below which will process the click. + } + } if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; const Rect panel = zonesControlPanel(bands); diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index 90a937c..bfa7275 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -356,6 +356,13 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) putU64le(out, asU64(pp.pitchEnv.attackFrames)); putU64le(out, asU64(pp.pitchEnv.decayFrames)); putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); + // S12 review fix (PAYLOAD v4): the full per-zone AHDSR A/D/S/R tail (always present in v4). + // Voice::start now uses the zone's full ADSR; old v3 blobs lift to tier-0 nominal defaults + // at read time (see readZonesPayload) so the voice sounds bit-identical to the pre-fix build. + putU64le(out, asU64(pp.adsr.attackFrames)); + putU64le(out, asU64(pp.adsr.decayFrames)); + putU64le(out, doubleToBits(pp.adsr.sustainLevel)); + putU64le(out, asU64(pp.adsr.releaseFrames)); } } @@ -367,11 +374,13 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) void readZonesPayload(ByteReader& r, PerformanceMap& map) { bool extended = false; // v2+: the S11 loop/start tail is present bool hasPlay = false; // v3+: the S15/S16 play-params tail is present + bool hasAdsr = false; // v4+: the full A/D/S/R per-zone tail is present if (r.peekU32() == kZonesFormatMarker) { r.u32(); // consume the marker const std::uint32_t pv = r.u32(); // payload version extended = (pv >= 2); // v2+ carries the loop/start tail hasPlay = (pv >= 3); // v3+ carries the S15/S16 play-params tail + hasAdsr = (pv >= 4); // v4+ carries the full A/D/S/R tail } const std::uint32_t count = r.u32(); for (std::uint32_t i = 0; i < count && r.ok; ++i) { @@ -397,7 +406,7 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) { if (hasStart) z.startPoint = r.i64(); } if (hasPlay) { - // S15/S16 play params, always present in a v3 record (read in the emit order). + // S15/S16 play params, always present in a v3+ record (read in the emit order). z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate; z.play.adsr.holdFrames = r.i64(); z.play.trigger.lengthFraction = bitsToDouble(r.u64()); @@ -409,6 +418,22 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) { z.play.pitchEnv.decayFrames = r.i64(); z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64()); } + if (hasAdsr) { + // S12 review fix (v4): the full per-zone A/D/S/R tail — read in the emit order. + z.play.adsr.attackFrames = r.i64(); + z.play.adsr.decayFrames = r.i64(); + z.play.adsr.sustainLevel = bitsToDouble(r.u64()); + z.play.adsr.releaseFrames = r.i64(); + } else if (hasPlay) { + // v3 blob: A/D/S/R fields are absent. Lift to the tier-0 nominal defaults (44100 Hz) + // so a voice playing this zone sounds bit-identical to the pre-v4 build (back-compat). + // Voice::start now uses the zone's full ADSR; a zone with these values reproduces + // the instrument-wide tier0Adsr behavior the pre-fix code applied unconditionally. + z.play.adsr.attackFrames = kTier0NominalAttackFrames; + z.play.adsr.decayFrames = kTier0NominalDecayFrames; + z.play.adsr.sustainLevel = kTier0NominalSustainLevel; + z.play.adsr.releaseFrames = kTier0NominalReleaseFrames; + } if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest map.zones.push_back(std::move(z)); } diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index b51430d..866bfef 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -107,6 +107,19 @@ std::vector downmixToMono(const std::vector& interleav std::vector extractChannel(const std::vector& interleaved, int channelCount, int which); +// Nominal tier-0 ADSR defaults (frames at 44100 Hz) — used when lifting a pre-v4 zones payload +// blob whose per-zone A/D/S/R fields are absent. These reproduce the instrument-wide tier0Adsr +// behavior (0.003 s attack, 0.0 s decay, sustain 1.0, 0.060 s release at 44100 Hz) so that a +// zone loaded from an old blob sounds bit-identical to the pre-v4 build. DAWs at other sample +// rates will be close but not exact (the same approximation the instrument already makes when +// building tier0Adsr from its compile-time kAttackSeconds / kReleaseSeconds constants). +// Must be declared before buildTier0Keymap (default arg) and PerformanceZone / ResolvedZone +// (member initializers) — both of which reference these values. +inline constexpr std::int64_t kTier0NominalAttackFrames = 132; // 0.003 * 44100, rounded +inline constexpr std::int64_t kTier0NominalDecayFrames = 0; +inline constexpr double kTier0NominalSustainLevel = 1.0; +inline constexpr std::int64_t kTier0NominalReleaseFrames = 2646; // 0.060 * 44100 + // Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole // keyboard, repitched from `rootNote`, looped per `loop`. The single-sample degenerate case // (Keymap::singleSampleChromatic) with the S2 intrinsics threaded in. `frames` is channel 0 @@ -122,8 +135,11 @@ Keymap buildTier0Keymap(std::vector frames, int sampleRate, int rootNote, const SampleLoop& loop, std::vector framesR = {}, const ZonePlayParams& play = ZonePlayParams{ - PlayMode::Gate, AdsrParams{}, TriggerParams{}, kDefaultPitchEngine, - PitchEnvParams{}}); + PlayMode::Gate, + AdsrParams{kTier0NominalAttackFrames, 0, + kTier0NominalDecayFrames, kTier0NominalSustainLevel, + kTier0NominalReleaseFrames}, + TriggerParams{}, kDefaultPitchEngine, PitchEnvParams{}}); // --- Performance map (Tier 1, D-B: the instrument's OWN state) --------------- // @@ -155,16 +171,19 @@ struct PerformanceZone { std::optional loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic std::optional startPoint; // instrument-owned initial read frame; absent -> 0 - // S15/S16 per-zone play parameters (play mode + AHDSR 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 // loop/start overrides. Defaults to the PRODUCT defaults for a NEW zone: Gate play mode, - // hold 0, no fades, and the PRESERVE pitch engine (S16-F1 — Daniel's directive; the one - // flippable default is sampler_core::kDefaultPitchEngine), pitch envelope off. An older - // zone-payload blob (no S15/S16 tail) lifts to exactly these defaults on read (see the - // PAYLOAD v3 versioning in the (de)serialize section), so a pre-S15 instrument opens with - // Gate + Preserve — the deliberate, spec-flagged behavior change. - ZonePlayParams play{PlayMode::Gate, AdsrParams{}, TriggerParams{}, kDefaultPitchEngine, - PitchEnvParams{}}; + // AHDSR with the tier-0 nominal A/D/S/R (kTier0Nominal* at 44100 Hz — the same values a + // v3 blob lifts to), hold 0, no fades, and the PRESERVE pitch engine (S16-F1), pitch env + // off. An older zone-payload blob (no S15/S16 tail or no v4 A/D/S/R tail) lifts to exactly + // these defaults on read (see the PAYLOAD v3/v4 versioning), so a pre-v4 instrument opens + // with Gate + Preserve + tier-0 ADSR — the deliberate back-compat path. + ZonePlayParams play{PlayMode::Gate, + AdsrParams{kTier0NominalAttackFrames, 0, + kTier0NominalDecayFrames, kTier0NominalSustainLevel, + kTier0NominalReleaseFrames}, + TriggerParams{}, kDefaultPitchEngine, PitchEnvParams{}}; }; // The instrument's performance map: an ordered list of zones. Order is authoritative for @@ -188,7 +207,11 @@ struct ResolvedZone { int rootNote = 60; // effective: override, else bank intrinsic, else 60 SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11) std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11) - ZonePlayParams play{PlayMode::Gate, AdsrParams{}, TriggerParams{}, kDefaultPitchEngine, + ZonePlayParams play{PlayMode::Gate, + AdsrParams{kTier0NominalAttackFrames, 0, + kTier0NominalDecayFrames, kTier0NominalSustainLevel, + kTier0NominalReleaseFrames}, + TriggerParams{}, kDefaultPitchEngine, PitchEnvParams{}}; // S15/S16 per-zone play params (carried through as-is) }; @@ -273,8 +296,7 @@ DecodedZonePcm decodeChannels(const std::vector& interleaved, // appended to each zone record after the S11 startPoint tail (the S15/S16 per-zone play // params — always present, NOT flag-gated, since every zone has a play mode + engine): // 1 byte playMode (0 = Gate, 1 = Trigger); -// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage (A/D/S/R timing stays -// instrument-wide; only hold is per-zone); +// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage; // 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE); // 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); // 1 byte pitchEngine (0 = Varispeed, 1 = Preserve); @@ -283,6 +305,14 @@ DecodedZonePcm decodeChannels(const std::vector& interleaved, // A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve + // no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved // instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest. +// * PAYLOAD v4 (S12 review fix): the same marker + payload version (== 4), THEN the v3 body +// PLUS, appended to each zone record after the v3 pitch-env tail, the full per-zone A/D/S/R: +// 8-byte LE adsr.attackFrames (int64); 8-byte LE adsr.decayFrames (int64); +// 8-byte LE adsr.sustainLevel as an IEEE-754 double (bit-cast to u64 LE); +// 8-byte LE adsr.releaseFrames (int64). +// A v3 payload (no v4 A/D/S/R tail) lifts those fields to the tier-0 nominal defaults at +// 44100 Hz (kTier0Nominal* constants) so a voice using the zone ADSR sounds bit-identical to +// the pre-v4 build. Voice::start now uses the zone's full ADSR for all five AHDSR fields. // BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is // lifted to a single full-keyboard zone playing that id (no override) — so an instance saved // under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes @@ -302,7 +332,7 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2; // (marker + version 2, no play tail) for back-compat, lifting the missing fields to defaults. // The marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in // practice, always tiny) can never collide with. -inline constexpr std::uint32_t kZonesPayloadVersion = 3; // S15/S16: per-zone play params tail +inline constexpr std::uint32_t kZonesPayloadVersion = 4; // S15/S16 A/D/S/R per-zone tail inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; // The performance map serialized to bytes for IBStream (getState). diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 4aa2f16..6cd2336 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -276,12 +276,21 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote readPos_ = static_cast(start); startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset) - // --- Amplitude envelope: Gate = AHDSR (instrument A/D/S/R + per-zone HOLD); Trigger = the - // time-boxed fade-in/out over the % play length. --- + // --- Amplitude envelope: Gate = AHDSR (fully per-zone: A/H/D/S/R all read from the zone's + // play.adsr); Trigger = the time-boxed fade-in/out over the % play length. + // + // gateAdsr (instrument-wide tier0) is the fallback for zones deserialized from a pre-v4 + // payload blob (see sample_map zones-payload v4): those zones carry their adsr fields + // pre-populated at deserialize time with the tier0 nominal defaults so back-compat holds + // (see sample_map.cpp readZonesPayload). For a new or fully-round-tripped zone the field + // is purely from the zone's own state; gateAdsr is still passed here for the Tier-0 + // single-capture path (buildTier0Keymap supplies a ZonePlayParams that already seeds the + // ADSR defaults from the product defaults; the voice reads those directly). + // + // Back-compat invariant: a zone whose adsr fields carry the product defaults (the tier0 + // nominal at 44100) sounds bit-identical to the pre-fix build. --- if (playMode_ == PlayMode::Gate) { - AdsrParams a = gateAdsr; - a.holdFrames = p.adsr.holdFrames; // per-zone hold folds into the instrument-wide AHDSR - env_.configure(a); + env_.configure(p.adsr); env_.noteOn(); playEnd_ = 0; // unused in Gate } else { diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 3607d11..5fcd4a3 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -1138,6 +1138,64 @@ static void testPlayParamsThroughComponentEnvelope() { CHECK(back.map.zones[0].play.pitchEngine == PitchEngine::Varispeed); } +// --- S12 review fix (PAYLOAD v4): full A/D/S/R per-zone round-trip. --------------------- +// +// Before the fix, per-zone A/D/S/R (attack/decay/sustain/release) was not serialized; +// only holdFrames was written. These two tests assert the corrected v4 path. + +// All five AHDSR fields (including the four new A/D/S/R) must round-trip through the v4 payload. +static void testFullAdsrV4RoundTrip() { + PerformanceMap m; + PerformanceZone z = zone("pad", 0, 127); + z.play.playMode = PlayMode::Gate; + z.play.adsr.attackFrames = 441; // 0.01 s at 44100 Hz (a non-default value) + z.play.adsr.holdFrames = 882; + z.play.adsr.decayFrames = 4410; // 0.1 s + z.play.adsr.sustainLevel = 0.7; + z.play.adsr.releaseFrames = 8820; // 0.2 s + z.play.pitchEngine = PitchEngine::Preserve; + m.zones.push_back(z); + const PerformanceMap back = deserializePerformance(serializePerformance(m)); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + const AdsrParams& a = back.zones[0].play.adsr; + CHECK(a.attackFrames == 441); + CHECK(a.holdFrames == 882); + CHECK(a.decayFrames == 4410); + CHECK(a.sustainLevel == 0.7); // exact double round-trip via bit-cast + CHECK(a.releaseFrames == 8820); + CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve); +} + +// A genuine PAYLOAD v3 blob (S15/S16 build — has holdFrames but not A/D/S/R) must lift +// attackFrames / decayFrames / sustainLevel / releaseFrames to the tier-0 nominal defaults +// (kTier0Nominal* constants) so the voice sounds bit-identical to the pre-fix behavior. +// We reuse handBuildV3PayloadOneZone which emits a valid marker-versioned v3 payload. +static void testV3BlobLiftsAdsrToNominalDefaults() { + // Build a PERFORMANCE blob: 4-byte kPerformanceStateVersion header + v3 zones payload. + // deserializePerformance strips the 4-byte header and passes the rest to readZonesPayload, + // which self-selects the v3 record shape from the payload marker+version — exercising the + // real production lift path for a user who saved on the S15 build. + std::vector blob; + auto u32 = [&](std::uint32_t v) { + blob.push_back(v & 0xFF); blob.push_back((v >> 8) & 0xFF); + blob.push_back((v >> 16) & 0xFF); blob.push_back((v >> 24) & 0xFF); + }; + u32(kPerformanceStateVersion); // envelope version 2 header + const std::vector payload = handBuildV3PayloadOneZone("old"); + blob.insert(blob.end(), payload.begin(), payload.end()); + const PerformanceMap back = deserializePerformance(blob); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + const AdsrParams& a = back.zones[0].play.adsr; + // holdFrames comes from the v3 record itself; A/D/S/R must lift to the nominal tier-0 values. + CHECK(a.holdFrames == 2048); // from the hand-built v3 record + CHECK(a.attackFrames == kTier0NominalAttackFrames); // 132 (0.003 s at 44100) + CHECK(a.decayFrames == kTier0NominalDecayFrames); // 0 + CHECK(a.sustainLevel == kTier0NominalSustainLevel); // 1.0 + CHECK(a.releaseFrames == kTier0NominalReleaseFrames); // 2646 (0.060 s at 44100) +} + int main() { testSelectByIdHit(); testSelectEmptyIdIsSilence(); @@ -1189,6 +1247,8 @@ int main() { testPlayParamsComposeWithLoopStart(); testPlayParamsV2BackCompatLiftsToDefaults(); testPlayParamsThroughComponentEnvelope(); + testFullAdsrV4RoundTrip(); + testV3BlobLiftsAdsrToNominalDefaults(); testComponentStateRoundTrip(); testComponentStateLoopStartRoundTrip(); testComponentStateSelectionOnlyNoZones(); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index b7282a4..7622d22 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -383,10 +383,14 @@ static void testOutOfZoneNoteConsumesNoVoice() { // --------------------------------------------------------------------------- static void testStealsReleasingVoiceFirst() { - Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60)); - AdsrParams a = flatAdsr(); - a.releaseFrames = 100000; // long release so a released voice stays "active". - VoiceEngine eng(2, km, a); + // Long per-zone release so the voice stays active through the release tail. + // Per the S12 fix, Voice::start uses sample.play.adsr — not the engine's gateAdsr — + // so the long release must live on the SampleData, not on the VoiceEngine constructor arg. + SampleData s = dcSample(100000, 60); + s.play.adsr = flatAdsr(); + s.play.adsr.releaseFrames = 100000; // long release so a released voice stays "active" + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(2, km, flatAdsr()); std::size_t vA = eng.noteOn(60, 100); // startOrder 1 std::size_t vB = eng.noteOn(62, 100); // startOrder 2 @@ -406,10 +410,12 @@ static void testStealsReleasingVoiceFirst() { } static void testStealsOldestWhenNoneReleasing() { - Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60)); - AdsrParams a = flatAdsr(); - a.releaseFrames = 100000; - VoiceEngine eng(2, km, a); + // Long per-zone release — placed on SampleData.play.adsr per the S12 fix. + SampleData s = dcSample(100000, 60); + s.play.adsr = flatAdsr(); + s.play.adsr.releaseFrames = 100000; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(2, km, flatAdsr()); std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest) std::size_t vB = eng.noteOn(62, 100); // startOrder 2 @@ -1177,6 +1183,57 @@ static void testPreserveVoiceCap() { CHECK(eng.activeVoiceCount() == 2); } +// --- S12 review fix: per-zone A/D/S/R actually reaches the voice envelope. --- +// +// Before the fix, Voice::start used the instrument-wide gateAdsr for A/D/S/R and only +// folded the per-zone holdFrames. These two tests assert the corrected path. + +// The zone's attackFrames drives the envelope ramp — NOT the VoiceEngine's gateAdsr. +// Strategy: give the VoiceEngine a FLAT gateAdsr (instant attack) but 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. If it instead used +// gateAdsr (flat = instant), frame 0 would already be 1.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, flatAdsr()); // instrument-wide gateAdsr = flat (instant attack) + eng.noteOn(60, 127); // unity pitch, full velocity -> gain 1.0 + std::vector out; + eng.render(out, 20); + // Frame 0: attack start, envelope near 0. If gateAdsr (flat) were used, this would be 1.0. + 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, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 5); + // All frames must be 1.0: zero attack + sustain 1.0 = instantly at full level. + for (std::size_t i = 0; i < out.size(); ++i) CHECK(approx(out[i], 1.0, 1e-9)); +} + int main() { testChromaticSingleRoot(); testZonedRangesBoundaries(); @@ -1228,6 +1285,10 @@ int main() { testPreserveGateStereoLoopComposes(); testPreserveVoiceCap(); + // S12 review fix — per-zone A/D/S/R reaches the voice envelope. + testPerZoneAdsrReachesVoiceEnvelope(); + testZeroAdsrIsInstantSustain(); + if (g_fail == 0) { std::printf("all sampler_core tests passed\n"); return 0; From 1d338318e7bbaf52ae65f094219eb44a8d21b3d6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 02:01:43 -0400 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20S12=20R2=20=E2=80=94=20marker-drag?= =?UTF-8?q?=20sets=20selectedZone=5F,=20ADSR=20rate-resolve,=20gateAdsr=20?= =?UTF-8?q?doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marker drag now sets selectedZone_ so single-capture controls stay reachable. Lifted/default ADSR rescales nominal 44100-Hz frame counts by sampleRate/44100 via adsrNeedsRateResolve; v4-authored zones skip rescale. gateAdsr documented vestigial. --- src/vst/reasampler_editor.cpp | 55 +++++++++++++++-------- src/vst/reasampler_editor.h | 5 ++- src/vst/sample_map.cpp | 35 +++++++++++++++ src/vst/sample_map.h | 27 +++++++++--- src/vst/sampler_core.cpp | 17 ++++---- src/vst/sampler_core.h | 12 ++--- tests/test_sample_map.cpp | 82 +++++++++++++++++++++++++++++++++++ 7 files changed, 192 insertions(+), 41 deletions(-) diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 9d2f7e6..0d4915c 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -269,32 +269,31 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram 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. // The bank intrinsic is NEVER written (read-only bank consumer, D-B). selectedId_ must // be non-empty; callers are responsible for that guard. + // Returns the zone index (0-based) so callers can update selectedZone_. SampleLoop loop; loop.hasLoop = m.hasLoop; loop.start = m.loopStart; loop.end = m.loopEnd; - bool found = false; - for (PerformanceZone& z : map_.zones) { + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + PerformanceZone& z = map_.zones[static_cast(i)]; if (z.sampleId == selectedId_) { z.loopOverride = loop; z.startPoint = m.start; - found = true; - break; + return i; } } - if (!found) { - PerformanceZone z; - z.sampleId = selectedId_; - z.lowNote = 0; - z.highNote = 127; - z.loopOverride = loop; - z.startPoint = m.start; - map_.zones.push_back(z); - } + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + z.loopOverride = loop; + z.startPoint = m.start; + map_.zones.push_back(z); + return static_cast(map_.zones.size()) - 1; } namespace { @@ -1322,6 +1321,14 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { dragParamPanel_ = panel; dragStartMap_ = map_; applyControl(id, z.play, valueAtPoint(r.control, x), 0); + // An explicit ADSR slider touch commits a rate-resolved value (the slider + // maps 0..1 -> editor-domain frames at kEnvTimeMaxFrames, not nominal 44100-Hz + // counts). Mark the zone as no longer needing rate-resolve so buildZonedKeymap + // does not re-rescale the value at reload time. + if (id >= static_cast(ParamControl::kAttack) && + id <= static_cast(ParamControl::kRelease)) { + z.adsrNeedsRateResolve = false; + } invalidate(); // live feedback; commit on WM_LBUTTONUP } break; @@ -1352,9 +1359,18 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { // zone over the whole keyboard) and round-trips through the v3 component state; the // zone becomes visible if the user opens the Zones panel. Upsert by the picked id so a // repeated drag edits the same zone rather than stacking duplicates. + // Upsert the root override on the picked id; track the zone index so the control panel + // stays visible after the zone is materialized on the single-capture face (fix: without + // setting selectedZone_ here, selectedZone_==-1 with a non-empty map hides controls). bool found = false; - for (PerformanceZone& z : map_.zones) { - if (z.sampleId == selectedId_) { z.rootOverride = note; found = true; break; } + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + PerformanceZone& z = map_.zones[static_cast(i)]; + if (z.sampleId == selectedId_) { + z.rootOverride = note; + selectedZone_ = i; + found = true; + break; + } } if (!found) { PerformanceZone z; @@ -1363,6 +1379,7 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { z.highNote = 127; z.rootOverride = note; map_.zones.push_back(z); + selectedZone_ = static_cast(map_.zones.size()) - 1; } invalidate(); // live feedback; the commit lands on WM_LBUTTONUP return; @@ -1407,8 +1424,10 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { if (m.start > frames - 1) m.start = frames - 1; // Upsert the override on the picked id (mirror of the root-marker path); commit lands on - // release, this is live feedback. - upsertPickedOverride(m); + // release, this is live feedback. Set selectedZone_ so the control panel stays visible + // after the zone is materialized (fix: without this, selectedZone_==-1 with a non-empty + // map hides controls after the first marker drag on the single-capture face). + selectedZone_ = upsertPickedOverride(m); invalidate(); return; } diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index d8af4af..acf8bd5 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -172,8 +172,9 @@ private: // Write `m` as a loop/start override upsert into map_ for selectedId_ (find-or-append). // Does NOT call commitAndReload — callers decide whether this is a live-drag update or a - // final commit. selectedId_ must be non-empty before calling. - void upsertPickedOverride(const SetupMarkers& m); + // final commit. selectedId_ must be non-empty before calling. Returns the zone index + // (0-based) that was updated or appended, so callers can set selectedZone_. + int upsertPickedOverride(const SetupMarkers& m); // --- S12/S15/S16 parameter surface (Zones panel, keyed to selectedZone_) ------ // diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index bfa7275..d0d44e8 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -147,6 +147,23 @@ Keymap buildTier0Keymap(std::vector frames, int sampleRate, data.rootNote = rootNote; data.loop = loop; data.play = play; // S15/S16 single-capture play params (product defaults unless overridden) + + // The default `play` arg carries 44100-Hz nominal ADSR frame counts (kTier0Nominal*). + // Rescale A/D/R by (sampleRate / 44100) so the wall-clock ADSR matches tier0Adsr(sampleRate) + // exactly. Sustain (a level, not a frame count) is unchanged. At 44100 the factor is 1.0 — + // bit-identical to the pre-fix build. The tier-0 single-capture path never carries user-edited + // ADSR (users edit ADSR through zones, which go through buildZonedKeymap), so always rescaling + // here is correct and safe. + if (data.sampleRate != 44100) { + const double factor = static_cast(data.sampleRate) / 44100.0; + data.play.adsr.attackFrames = static_cast( + static_cast(data.play.adsr.attackFrames) * factor + 0.5); + data.play.adsr.decayFrames = static_cast( + static_cast(data.play.adsr.decayFrames) * factor + 0.5); + data.play.adsr.releaseFrames = static_cast( + static_cast(data.play.adsr.releaseFrames) * factor + 0.5); + } + return Keymap::singleSampleChromatic(std::move(data)); } @@ -190,6 +207,8 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson, // S15/S16 per-zone play params carry through unchanged (they are instrument state, not // resolved against the bank) so the keymap build can stamp them onto the SampleData. rz.play = z.play; + // Carry the rate-resolve flag so buildZonedKeymap can rescale 44100-nominal ADSR counts. + rz.adsrNeedsRateResolve = z.adsrNeedsRateResolve; out.zones.push_back(std::move(rz)); } return out; @@ -215,6 +234,20 @@ Keymap buildZonedKeymap(const std::vector& zones, data.loop = zones[i].loop; data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0) data.play = zones[i].play; // S15/S16 per-zone play mode + engine + envelopes + + // If the zone's ADSR came from a v3 lift or a new-zone default (adsrNeedsRateResolve), + // its A/D/R frame counts are 44100-Hz nominals. Rescale to the WAV's actual rate so + // wall-clock ADSR durations match tier0Adsr(sampleRate) exactly. v4 zones (user-edited + // frame counts) carry adsrNeedsRateResolve=false and are left unchanged. + if (zones[i].adsrNeedsRateResolve && data.sampleRate != 44100) { + const double factor = static_cast(data.sampleRate) / 44100.0; + data.play.adsr.attackFrames = static_cast( + static_cast(data.play.adsr.attackFrames) * factor + 0.5); + data.play.adsr.decayFrames = static_cast( + static_cast(data.play.adsr.decayFrames) * factor + 0.5); + data.play.adsr.releaseFrames = static_cast( + static_cast(data.play.adsr.releaseFrames) * factor + 0.5); + } const std::size_t sampleIndex = km.samples.size(); km.samples.push_back(std::move(data)); KeyZone zone; @@ -420,10 +453,12 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) { } if (hasAdsr) { // S12 review fix (v4): the full per-zone A/D/S/R tail — read in the emit order. + // These values were authored at the DAW's rate; mark them resolved (no rescaling). z.play.adsr.attackFrames = r.i64(); z.play.adsr.decayFrames = r.i64(); z.play.adsr.sustainLevel = bitsToDouble(r.u64()); z.play.adsr.releaseFrames = r.i64(); + z.adsrNeedsRateResolve = false; // already rate-resolved; do NOT rescale at keymap build } else if (hasPlay) { // v3 blob: A/D/S/R fields are absent. Lift to the tier-0 nominal defaults (44100 Hz) // so a voice playing this zone sounds bit-identical to the pre-v4 build (back-compat). diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index 866bfef..76edc4b 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -108,11 +108,13 @@ std::vector extractChannel(const std::vector& interlea int channelCount, int which); // Nominal tier-0 ADSR defaults (frames at 44100 Hz) — used when lifting a pre-v4 zones payload -// blob whose per-zone A/D/S/R fields are absent. These reproduce the instrument-wide tier0Adsr -// behavior (0.003 s attack, 0.0 s decay, sustain 1.0, 0.060 s release at 44100 Hz) so that a -// zone loaded from an old blob sounds bit-identical to the pre-v4 build. DAWs at other sample -// rates will be close but not exact (the same approximation the instrument already makes when -// building tier0Adsr from its compile-time kAttackSeconds / kReleaseSeconds constants). +// blob whose per-zone A/D/S/R fields are absent, and as the initializer for new zones / the +// buildTier0Keymap default play arg. These are 44100-Hz nominal frame counts; buildTier0Keymap +// and buildZonedKeymap both rescale the A/D/R frame counts by (sampleRate / 44100) at build +// time when adsrNeedsRateResolve is set on the zone — so a v3-lifted or default zone plays with +// the same wall-clock ADSR as tier0Adsr(liveRate) did before the fix. Bit-identical at 44100 Hz. +// Zones loaded from a v4 blob (explicitly user-edited) carry adsrNeedsRateResolve=false and are +// never rescaled — their stored frame counts already reflect the rate at which they were authored. // Must be declared before buildTier0Keymap (default arg) and PerformanceZone / ResolvedZone // (member initializers) — both of which reference these values. inline constexpr std::int64_t kTier0NominalAttackFrames = 132; // 0.003 * 44100, rounded @@ -129,8 +131,9 @@ inline constexpr std::int64_t kTier0NominalReleaseFrames = 2646; // 0.060 * 441 // 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 // the PRODUCT defaults (Gate + Preserve engine, S16-F1) so a picked single capture plays under -// the same default engine as a zone would. The editor will surface per-capture overrides later -// (S15-F2 one-zone-map lean); until then this is the one place the single-capture default lives. +// the same default engine as a zone would. The A/D/R frame counts in the default play arg are +// 44100-Hz nominals; this function always rescales them by (sampleRate / 44100) before stamping +// them on the SampleData so the ADSR wall-clock durations match tier0Adsr(sampleRate) exactly. Keymap buildTier0Keymap(std::vector frames, int sampleRate, int rootNote, const SampleLoop& loop, std::vector framesR = {}, @@ -184,6 +187,13 @@ struct PerformanceZone { kTier0NominalDecayFrames, kTier0NominalSustainLevel, kTier0NominalReleaseFrames}, TriggerParams{}, kDefaultPitchEngine, PitchEnvParams{}}; + + // When true, the A/D/R frame counts in play.adsr are 44100-Hz nominals (either lifted from a + // v3 blob or defaulted for a new zone) that must be rescaled by (sampleRate / 44100) at keymap + // build time (buildZonedKeymap). Set to false when a v4 blob explicitly provides A/D/S/R (the + // stored counts already reflect the DAW rate at the time the user edited them) or when the user + // edits an ADSR slider (the committed value is already editor-domain). Never serialized. + bool adsrNeedsRateResolve = true; }; // The instrument's performance map: an ordered list of zones. Order is authoritative for @@ -213,6 +223,9 @@ struct ResolvedZone { kTier0NominalReleaseFrames}, TriggerParams{}, kDefaultPitchEngine, PitchEnvParams{}}; // S15/S16 per-zone play params (carried through as-is) + // Carried from PerformanceZone::adsrNeedsRateResolve — buildZonedKeymap rescales A/D/R + // frames by (sampleRate / 44100) when true. False for v4-explicit or user-edited values. + bool adsrNeedsRateResolve = true; }; // The result of resolving a performance map against the live bank blob. `zones` are the diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 6cd2336..82734fe 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -279,16 +279,15 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote // --- Amplitude envelope: Gate = AHDSR (fully per-zone: A/H/D/S/R all read from the zone's // play.adsr); Trigger = the time-boxed fade-in/out over the % play length. // - // gateAdsr (instrument-wide tier0) is the fallback for zones deserialized from a pre-v4 - // payload blob (see sample_map zones-payload v4): those zones carry their adsr fields - // pre-populated at deserialize time with the tier0 nominal defaults so back-compat holds - // (see sample_map.cpp readZonesPayload). For a new or fully-round-tripped zone the field - // is purely from the zone's own state; gateAdsr is still passed here for the Tier-0 - // single-capture path (buildTier0Keymap supplies a ZonePlayParams that already seeds the - // ADSR defaults from the product defaults; the voice reads those directly). + // All five AHDSR fields come from sample.play.adsr, stamped by buildTier0Keymap / + // buildZonedKeymap at reload time (with rate-rescaling for v3-lifted / default zones). + // gateAdsr (the VoiceEngine's instrument-wide ADSR) is accepted for interface compat + // but is NOT read here — it is vestigial since the S12 review fix moved A/D/S/R fully + // onto the per-zone SampleData. // - // Back-compat invariant: a zone whose adsr fields carry the product defaults (the tier0 - // nominal at 44100) sounds bit-identical to the pre-fix build. --- + // Back-compat invariant: a zone whose adsr fields carry the tier-0 nominal values + // (rescaled to the live sample rate by buildZonedKeymap) sounds bit-identical to the + // pre-fix build at every DAW rate. --- if (playMode_ == PlayMode::Gate) { env_.configure(p.adsr); env_.noteOn(); diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index b4a09d7..e552190 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -340,11 +340,13 @@ class Voice { public: // Starts this voice on `note` at `velocity`, playing `sample` (a stable reference // the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched - // from `rootNote`. `gateAdsr` is the effective Gate AHDSR (the engine supplies the - // instrument-wide attack/decay/sustain/release timing; the per-zone HOLD stage comes from - // sample.play.adsr.holdFrames, folded in here). The S15 play MODE + Trigger params and the - // S16 pitch ENGINE + pitch envelope are read from `sample.play`. The Preserve shifters MUST - // already be pre-sized (presizePreserveShifters, off-thread) — start() only reset()s + warm()s + // from `rootNote`. All five AHDSR fields (A/H/D/S/R) are read directly from + // sample.play.adsr — the per-zone values stamped by buildTier0Keymap / buildZonedKeymap. + // `gateAdsr` is the VoiceEngine's instrument-wide ADSR parameter, accepted for interface + // compatibility but NOT used by start() (vestigial since the S12 review fix moved A/D/S/R + // onto the per-zone SampleData). The S15 play MODE + Trigger params and the S16 pitch + // ENGINE + pitch envelope are read from `sample.play`. The Preserve shifters MUST already + // be pre-sized (presizePreserveShifters, off-thread) — start() only reset()s + warm()s // them (RT-safe, no allocation) since it runs on the audio thread inside process(). The warm // silence pass settles the OLA taps before the first output frame (no cold-start click). // Byte-identical to the pre-S15 engine when sample.play is default (Gate + Varispeed + no diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 5fcd4a3..339972d 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -1194,6 +1194,84 @@ static void testV3BlobLiftsAdsrToNominalDefaults() { CHECK(a.decayFrames == kTier0NominalDecayFrames); // 0 CHECK(a.sustainLevel == kTier0NominalSustainLevel); // 1.0 CHECK(a.releaseFrames == kTier0NominalReleaseFrames); // 2646 (0.060 s at 44100) + // The lifted zone must be flagged for rate-resolve so buildZonedKeymap rescales at the live rate. + CHECK(back.zones[0].adsrNeedsRateResolve == true); +} + +// A v4 blob (explicit A/D/S/R tail) must NOT set adsrNeedsRateResolve — those values +// were authored at the DAW's rate and must not be rescaled again at keymap build time. +static void testV4BlobClearsAdsrNeedsRateResolve() { + PerformanceMap m; + PerformanceZone z = zone("pad", 0, 127); + z.play.adsr.attackFrames = 441; + z.play.adsr.releaseFrames = 8820; + m.zones.push_back(z); + // A round-trip through serialize/deserialize writes a v4 payload (current version). + const PerformanceMap back = deserializePerformance(serializePerformance(m)); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + // v4 tail was explicitly read — adsrNeedsRateResolve must be false. + CHECK(back.zones[0].adsrNeedsRateResolve == false); +} + +// buildTier0Keymap at 48k must produce ADSR frame counts equal to tier0Adsr(48000): +// attack = round(kTier0NominalAttackFrames * 48000 / 44100) = round(143.67) = 144, +// release = round(kTier0NominalReleaseFrames * 48000 / 44100) = round(2880.0) = 2880. +// This is the "pre-fix, the Gate voice used tier0Adsr(sampleRate_)" invariant restored +// for the single-capture fast path at any DAW rate. +static void testBuildTier0KeymapRescalesAdsrAt48k() { + 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; + // Rescaled from 44100-nominal at 48000 Hz: + CHECK(a.attackFrames == 144); // round(132 * 48000.0 / 44100.0) + CHECK(a.decayFrames == 0); // 0 * factor = 0 (no change) + CHECK(a.sustainLevel == 1.0); // level, not frames (no rescale) + CHECK(a.releaseFrames == 2880); // round(2646 * 48000.0 / 44100.0) +} + +// buildZonedKeymap at 48k with adsrNeedsRateResolve=true must rescale ADSR to match +// tier0Adsr(48000), mirroring what Gate voices saw before the per-zone-ADSR fix. +static void testBuildZonedKeymapRescalesNominalAdsrAt48k() { + // Build a zone with nominal 44100-Hz ADSR and the needs-resolve flag (the default). + ResolvedZone z; + z.lowNote = 0; z.highNote = 127; z.rootNote = 60; + z.adsrNeedsRateResolve = true; // 44100-nominal values, rescale needed + z.play.adsr.attackFrames = kTier0NominalAttackFrames; // 132 + z.play.adsr.decayFrames = kTier0NominalDecayFrames; // 0 + z.play.adsr.sustainLevel = kTier0NominalSustainLevel; // 1.0 + z.play.adsr.releaseFrames = kTier0NominalReleaseFrames; // 2646 + const DecodedZonePcm pcm{{0.5f}, 48000}; // 48k WAV + const Keymap km = buildZonedKeymap({z}, {pcm}); + CHECK(km.samples.size() == 1); + if (km.samples.empty()) return; + const AdsrParams& a = km.samples[0].play.adsr; + CHECK(a.attackFrames == 144); // round(132 * 48000.0 / 44100.0) + CHECK(a.decayFrames == 0); // 0 * factor = 0 + CHECK(a.sustainLevel == 1.0); // level, not rescaled + CHECK(a.releaseFrames == 2880); // round(2646 * 48000.0 / 44100.0) +} + +// buildZonedKeymap must NOT rescale a zone whose adsrNeedsRateResolve is false — +// those frame counts are explicitly user-authored at the DAW's rate. +static void testBuildZonedKeymapDoesNotRescaleV4Adsr() { + ResolvedZone z; + z.lowNote = 0; z.highNote = 127; z.rootNote = 60; + z.adsrNeedsRateResolve = false; // v4 blob or user-edited — do not rescale + z.play.adsr.attackFrames = 441; // 0.01 s at 44100 Hz (non-nominal) + z.play.adsr.decayFrames = 4410; + z.play.adsr.sustainLevel = 0.7; + z.play.adsr.releaseFrames = 8820; + const DecodedZonePcm pcm{{0.5f}, 48000}; // 48k WAV — rescale would change values + const Keymap km = buildZonedKeymap({z}, {pcm}); + CHECK(km.samples.size() == 1); + if (km.samples.empty()) return; + const AdsrParams& a = km.samples[0].play.adsr; + CHECK(a.attackFrames == 441); // unchanged (not rescaled) + CHECK(a.decayFrames == 4410); + CHECK(a.sustainLevel == 0.7); + CHECK(a.releaseFrames == 8820); } int main() { @@ -1249,6 +1327,10 @@ int main() { testPlayParamsThroughComponentEnvelope(); testFullAdsrV4RoundTrip(); testV3BlobLiftsAdsrToNominalDefaults(); + testV4BlobClearsAdsrNeedsRateResolve(); + testBuildTier0KeymapRescalesAdsrAt48k(); + testBuildZonedKeymapRescalesNominalAdsrAt48k(); + testBuildZonedKeymapDoesNotRescaleV4Adsr(); testComponentStateRoundTrip(); testComponentStateLoopStartRoundTrip(); testComponentStateSelectionOnlyNoZones(); From a54af277e43e6711bad898b0ad08a86bcb9b6a40 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 02:51:46 -0400 Subject: [PATCH 4/6] S12: store wall-clock ADSR/pitch-env as seconds, resolve to frames at live rate Kill kTier0Nominal*, adsrNeedsRateResolve, tier0Adsr, gateAdsr. Zones payload v5 carries seconds; v3 legacy reads convert at the frozen authoring rate; v4 (branch-only) dropped. Editor sliders now seconds. Engine takes frames resolved at keymap build. --- src/vst/reasampler_editor.cpp | 64 +++--- src/vst/reasampler_editor.h | 16 +- src/vst/reasampler_processor.cpp | 21 +- src/vst/reasampler_processor.h | 4 +- src/vst/sample_map.cpp | 167 ++++++++------- src/vst/sample_map.h | 167 ++++++++------- src/vst/sampler_core.cpp | 23 +- src/vst/sampler_core.h | 47 ++--- tests/test_sample_map.cpp | 348 ++++++++++++++++++++----------- tests/test_sampler_core.cpp | 115 +++++----- 10 files changed, 539 insertions(+), 433 deletions(-) diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 0d4915c..ffd20c2 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -298,19 +298,19 @@ int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) { namespace { // The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is -// engine-free and maps only 0..1). Time sliders span [0, max] frames at the NOMINAL 44100 Hz -// rate; the stored frame count is host-rate-independent, so at other DAW rates the same slider -// position maps to a slightly different wall-clock duration. The ceiling is kept nominal-only -// because the host rate is not reachable inside the editor without a processor callback, and the -// approximation is musically negligible (±1-2 ms at typical rates). Build-time residual — one -// place to retune; not persisted. -constexpr double kEnvTimeMaxFrames = 2.0 * 44100.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (44100 nominal) +// engine-free and maps only 0..1). WALL-CLOCK time sliders (AHDSR A/H/D/R, pitch env A/D) span +// [0, kEnvTimeMaxSeconds] SECONDS — rate-free, exactly what the zone stores; the keymap build +// resolves seconds->frames at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out) +// span [0, kFadeMaxFrames] SOURCE frames (a source-timeline quantity, PLAN.md §S15 — never a +// wall-clock second). Build-time residual — one place to retune; not persisted. +constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds) +constexpr double kFadeMaxFrames = 88200.0; // Trigger fade throw ceiling (source frames) constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); } } // namespace -std::vector ReaSamplerEditor::controlDescs(const ZonePlayParams& play) const { +std::vector ReaSamplerEditor::controlDescs(const ZonePlaySeconds& play) const { std::vector out; // Always: the two mode toggles. out.push_back({static_cast(ParamControl::kPlayMode), ControlKind::Toggle}); @@ -335,24 +335,27 @@ std::vector ReaSamplerEditor::controlDescs(const ZonePlayParams& pl return out; } -double ReaSamplerEditor::controlValue(int id, const ZonePlayParams& play) const { +double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const { + // Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over + // the frames ceiling. Two domains, kept explicit so neither leaks a rate. + const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); }; const auto framesToNorm = [](std::int64_t f) { - return clamp01(static_cast(f) / kEnvTimeMaxFrames); + return clamp01(static_cast(f) / kFadeMaxFrames); }; switch (static_cast(id)) { case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0; case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0; - case ParamControl::kAttack: return framesToNorm(play.adsr.attackFrames); - case ParamControl::kHold: return framesToNorm(play.adsr.holdFrames); - case ParamControl::kDecay: return framesToNorm(play.adsr.decayFrames); + 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 framesToNorm(play.adsr.releaseFrames); + 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 framesToNorm(play.pitchEnv.attackFrames); - case ParamControl::kPitchEnvDecay: return framesToNorm(play.pitchEnv.decayFrames); + 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)); @@ -360,10 +363,11 @@ double ReaSamplerEditor::controlValue(int id, const ZonePlayParams& play) const } } -void ReaSamplerEditor::applyControl(int id, ZonePlayParams& play, double value, +void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value, int segment) const { + const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; }; const auto normToFrames = [](double v) { - return static_cast(clamp01(v) * kEnvTimeMaxFrames + 0.5); + return static_cast(clamp01(v) * kFadeMaxFrames + 0.5); }; switch (static_cast(id)) { case ParamControl::kPlayMode: @@ -372,11 +376,11 @@ void ReaSamplerEditor::applyControl(int id, ZonePlayParams& play, double value, case ParamControl::kPitchEngine: play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed; break; - case ParamControl::kAttack: play.adsr.attackFrames = normToFrames(value); break; - case ParamControl::kHold: play.adsr.holdFrames = normToFrames(value); break; - case ParamControl::kDecay: play.adsr.decayFrames = normToFrames(value); 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.releaseFrames = normToFrames(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)); @@ -386,8 +390,8 @@ void ReaSamplerEditor::applyControl(int id, ZonePlayParams& play, double value, case ParamControl::kPitchEnvEnable: play.pitchEnv.enabled = (segment == 1); break; - case ParamControl::kPitchEnvAttack: play.pitchEnv.attackFrames = normToFrames(value); break; - case ParamControl::kPitchEnvDecay: play.pitchEnv.decayFrames = normToFrames(value); 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; @@ -999,7 +1003,7 @@ void ReaSamplerEditor::paintControls(LICE_IBitmap* bmp, const Rect& panel) { // 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). - ZonePlayParams play; + ZonePlaySeconds play; if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { play = map_.zones[static_cast(selectedZone_)].play; } else if (map_.zones.empty() && !selectedId_.empty()) { @@ -1284,7 +1288,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { // 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 ZonePlayParams defaultPlay = PerformanceZone{}.play; + const ZonePlaySeconds defaultPlay = PerformanceZone{}.play; const std::vector probeDescs = controlDescs(defaultPlay); const std::vector probeRows = layoutControls(panel, probeDescs); if (controlAtPoint(probeRows, x, y) >= 0) { @@ -1321,14 +1325,6 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { dragParamPanel_ = panel; dragStartMap_ = map_; applyControl(id, z.play, valueAtPoint(r.control, x), 0); - // An explicit ADSR slider touch commits a rate-resolved value (the slider - // maps 0..1 -> editor-domain frames at kEnvTimeMaxFrames, not nominal 44100-Hz - // counts). Mark the zone as no longer needing rate-resolve so buildZonedKeymap - // does not re-rescale the value at reload time. - if (id >= static_cast(ParamControl::kAttack) && - id <= static_cast(ParamControl::kRelease)) { - z.adsrNeedsRateResolve = false; - } invalidate(); // live feedback; commit on WM_LBUTTONUP } break; diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index acf8bd5..32be032 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -178,22 +178,24 @@ private: // --- S12/S15/S16 parameter surface (Zones panel, keyed to selectedZone_) ------ // - // The control panel edits the SELECTED zone's ZonePlayParams (S15 play mode + AHDSR; S16 - // pitch engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact. + // The control panel edits the SELECTED zone's ZonePlaySeconds (S15 play mode + AHDSR; S16 + // pitch engine + AD pitch envelope). Wall-clock times are SECONDS (rate-free); the keymap + // build resolves them to frames at the live rate. Instrument-owned (D-B), never a bank fact. // The control descriptors the panel shows for `play`'s CURRENT play mode: the two toggles + // the mode-relevant sliders (AHDSR for Gate, %-length/fades for Trigger) + the pitch-envelope // controls. The pure param_slider lays these out; this only picks the set. Static (a free // choice of set from the mode) — kept a member for the ParamControl enum access. - std::vector controlDescs(const ZonePlayParams& play) const; + std::vector controlDescs(const ZonePlaySeconds& play) const; // The normalized [0,1] display value for control `id` given `play` (the shell's domain - // mapping: frames->0..1 over a fixed max, sustain 0..1 as-is, semitone depth centered at 0.5). - double controlValue(int id, const ZonePlayParams& play) const; + // 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 engine domain) or a toggle's `segment` (0/1). Mutates `play` in place. - void applyControl(int id, ZonePlayParams& play, double value, int segment) const; + // 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; diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 92f2448..c9baef0 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -37,10 +37,6 @@ namespace { // notes neither click on nor cut off abruptly; sustain at unity (velocity does the // dynamics), a short release for a natural tail. Times are in seconds, converted to // frames against the live sample rate at build time. -constexpr 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; // 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. 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(kAttackSeconds * sr); - p.decayFrames = static_cast(kDecaySeconds * sr); - p.sustainLevel = kSustainLevel; - p.releaseFrames = static_cast(kReleaseSeconds * sr); - return p; -} - // Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on // any failure — the caller treats an unreadable WAV as "nothing to play". std::vector readFileBytes(const std::string& path) { @@ -384,12 +370,13 @@ std::string ReaSamplerProcessor::reloadFromBank() { if (haveKeymap) { // Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs). // Every voice's shifter is pre-sized to this off-thread here, so process()-time - // note-on never allocates. Floored at 2 so a valid window is always a real ring. + // note-on never allocates. Floored at 2 so a valid window is always a real ring + // (which also covers a pathological host rate <= 0 — no rate literal needed). std::int64_t preserveWindow = static_cast( - kPreserveWindowMs * (sampleRate_ > 0.0 ? sampleRate_ : 44100.0) / 1000.0 + 0.5); + kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); if (preserveWindow < 2) preserveWindow = 2; built = std::make_unique( - std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen, kPreserveVoiceCap, + std::move(km), kMaxVoices, gen, kPreserveVoiceCap, preserveWindow); } } diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index 531aea6..b9c4299 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -50,11 +50,11 @@ struct LoadedInstrument { VoiceEngine engine; 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::int64_t preserveWindowFrames = 0) : keymap(std::move(km)), - engine(maxVoices, keymap, adsr, preserveVoiceCap, preserveWindowFrames), + engine(maxVoices, keymap, preserveVoiceCap, preserveWindowFrames), installedAt(gen) {} LoadedInstrument(const LoadedInstrument&) = delete; diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index d0d44e8..c80fee5 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -133,9 +133,35 @@ DecodedZonePcm decodeChannels(const std::vector& interleaved, 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. + const double sr = sampleRate > 0 ? static_cast(sampleRate) : 44100.0; + const auto secToFrames = [sr](double sec) { + double f = sec * sr; + if (f < 0.0) f = 0.0; + return static_cast(f + 0.5); + }; + ZonePlayParams out; + out.playMode = stored.playMode; + out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds); + out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds); + out.adsr.decayFrames = secToFrames(stored.adsr.decaySeconds); + out.adsr.sustainLevel = stored.adsr.sustainLevel; // level, not a time + out.adsr.releaseFrames = secToFrames(stored.adsr.releaseSeconds); + out.trigger = stored.trigger; // source-frame / fraction, unchanged + out.pitchEngine = stored.pitchEngine; + out.pitchEnv.enabled = stored.pitchEnv.enabled; + out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds); + out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds); + out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time + return out; +} + Keymap buildTier0Keymap(std::vector frames, int sampleRate, int rootNote, const SampleLoop& loop, - std::vector framesR, const ZonePlayParams& play) { + std::vector framesR, const ZonePlaySeconds& play) { SampleData data; data.frames = std::move(frames); // A second channel only counts when it length-matches channel 0 (else the sample stays @@ -146,23 +172,8 @@ Keymap buildTier0Keymap(std::vector frames, int sampleRate, data.sampleRate = sampleRate > 0 ? sampleRate : 44100; data.rootNote = rootNote; data.loop = loop; - data.play = play; // S15/S16 single-capture play params (product defaults unless overridden) - - // The default `play` arg carries 44100-Hz nominal ADSR frame counts (kTier0Nominal*). - // Rescale A/D/R by (sampleRate / 44100) so the wall-clock ADSR matches tier0Adsr(sampleRate) - // exactly. Sustain (a level, not a frame count) is unchanged. At 44100 the factor is 1.0 — - // bit-identical to the pre-fix build. The tier-0 single-capture path never carries user-edited - // ADSR (users edit ADSR through zones, which go through buildZonedKeymap), so always rescaling - // here is correct and safe. - if (data.sampleRate != 44100) { - const double factor = static_cast(data.sampleRate) / 44100.0; - data.play.adsr.attackFrames = static_cast( - static_cast(data.play.adsr.attackFrames) * factor + 0.5); - data.play.adsr.decayFrames = static_cast( - static_cast(data.play.adsr.decayFrames) * factor + 0.5); - data.play.adsr.releaseFrames = static_cast( - static_cast(data.play.adsr.releaseFrames) * factor + 0.5); - } + // 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)); } @@ -204,11 +215,9 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson, // never mutated — this only shapes what the core plays for THIS instance (D-B). rz.loop = z.loopOverride ? *z.loopOverride : loopFromSample(*found); rz.startFrame = z.startPoint ? *z.startPoint : 0; - // S15/S16 per-zone play params carry through unchanged (they are instrument state, not - // resolved against the bank) so the keymap build can stamp them onto the SampleData. + // S15/S16 per-zone play params (SECONDS) carry through unchanged (they are instrument + // state, not resolved against the bank); buildZonedKeymap resolves them to frames. rz.play = z.play; - // Carry the rate-resolve flag so buildZonedKeymap can rescale 44100-nominal ADSR counts. - rz.adsrNeedsRateResolve = z.adsrNeedsRateResolve; out.zones.push_back(std::move(rz)); } return out; @@ -233,21 +242,9 @@ Keymap buildZonedKeymap(const std::vector& zones, data.rootNote = zones[i].rootNote; data.loop = zones[i].loop; data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0) - data.play = zones[i].play; // S15/S16 per-zone play mode + engine + envelopes - - // If the zone's ADSR came from a v3 lift or a new-zone default (adsrNeedsRateResolve), - // its A/D/R frame counts are 44100-Hz nominals. Rescale to the WAV's actual rate so - // wall-clock ADSR durations match tier0Adsr(sampleRate) exactly. v4 zones (user-edited - // frame counts) carry adsrNeedsRateResolve=false and are left unchanged. - if (zones[i].adsrNeedsRateResolve && data.sampleRate != 44100) { - const double factor = static_cast(data.sampleRate) / 44100.0; - data.play.adsr.attackFrames = static_cast( - static_cast(data.play.adsr.attackFrames) * factor + 0.5); - data.play.adsr.decayFrames = static_cast( - static_cast(data.play.adsr.decayFrames) * factor + 0.5); - data.play.adsr.releaseFrames = static_cast( - static_cast(data.play.adsr.releaseFrames) * factor + 0.5); - } + // Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's + // actual rate; source-timeline params (trigger %-length + fades, start) carry through. + data.play = resolvePlay(zones[i].play, data.sampleRate); const std::size_t sampleIndex = km.samples.size(); km.samples.push_back(std::move(data)); KeyZone zone; @@ -376,26 +373,25 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) out.push_back(z.startPoint ? 1 : 0); if (z.startPoint) putU64le(out, asU64(*z.startPoint)); - // S15/S16 extension (PAYLOAD v3): the per-zone play params, always present (every zone - // has a play mode + engine — no flag gate). Order matches the header's v3 record spec. - const ZonePlayParams& pp = z.play; + // S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine). + // Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames / + // fraction. Order matches the header's v5 record spec. + const ZonePlaySeconds& pp = z.play; out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0); - putU64le(out, asU64(pp.adsr.holdFrames)); - putU64le(out, doubleToBits(pp.trigger.lengthFraction)); - putU64le(out, asU64(pp.trigger.fadeInFrames)); - putU64le(out, asU64(pp.trigger.fadeOutFrames)); + putU64le(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds + putU64le(out, doubleToBits(pp.trigger.lengthFraction)); // fraction + putU64le(out, asU64(pp.trigger.fadeInFrames)); // source frames + putU64le(out, asU64(pp.trigger.fadeOutFrames)); // source frames out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0); out.push_back(pp.pitchEnv.enabled ? 1 : 0); - putU64le(out, asU64(pp.pitchEnv.attackFrames)); - putU64le(out, asU64(pp.pitchEnv.decayFrames)); - putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); - // S12 review fix (PAYLOAD v4): the full per-zone AHDSR A/D/S/R tail (always present in v4). - // Voice::start now uses the zone's full ADSR; old v3 blobs lift to tier-0 nominal defaults - // at read time (see readZonesPayload) so the voice sounds bit-identical to the pre-fix build. - putU64le(out, asU64(pp.adsr.attackFrames)); - putU64le(out, asU64(pp.adsr.decayFrames)); + putU64le(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds + putU64le(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds + putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth + // Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level). + putU64le(out, doubleToBits(pp.adsr.attackSeconds)); + putU64le(out, doubleToBits(pp.adsr.decaySeconds)); putU64le(out, doubleToBits(pp.adsr.sustainLevel)); - putU64le(out, asU64(pp.adsr.releaseFrames)); + putU64le(out, doubleToBits(pp.adsr.releaseSeconds)); } } @@ -405,20 +401,19 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) // clean back-compat lift, the overrides simply default absent). A truncated mid-zone read // keeps the zones that parsed cleanly and drops the rest. void readZonesPayload(ByteReader& r, PerformanceMap& map) { - bool extended = false; // v2+: the S11 loop/start tail is present - bool hasPlay = false; // v3+: the S15/S16 play-params tail is present - bool hasAdsr = false; // v4+: the full A/D/S/R per-zone tail is present + bool extended = false; // v2+: the S11 loop/start tail is present + std::uint32_t pv = 0; // payload version (0 = v1, no marker) if (r.peekU32() == kZonesFormatMarker) { r.u32(); // consume the marker - const std::uint32_t pv = r.u32(); // payload version + pv = r.u32(); // payload version extended = (pv >= 2); // v2+ carries the loop/start tail - hasPlay = (pv >= 3); // v3+ carries the S15/S16 play-params tail - hasAdsr = (pv >= 4); // v4+ carries the full A/D/S/R tail } + const bool legacyV3Play = (pv == 3); // legacy S15/S16 play tail, wall-clock in 44.1k frames + const bool secondsPlay = (pv >= 5); // current: full play params, wall-clock in seconds const std::uint32_t count = r.u32(); for (std::uint32_t i = 0; i < count && r.ok; ++i) { - // z.play defaults to the PRODUCT defaults (Gate + Preserve). A v1/v2 payload (no play - // tail) therefore lifts every zone to those defaults — the deliberate S16-F1 change. + // z.play defaults to the PRODUCT defaults (Gate + Preserve + tier-0 AHDSR seconds). A + // v1/v2 payload (no play tail) therefore lifts every zone to those defaults (S16-F1). PerformanceZone z; const std::uint32_t idLen = r.u32(); z.sampleId = r.str(idLen); @@ -438,37 +433,41 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) { const std::uint8_t hasStart = r.u8(); if (hasStart) z.startPoint = r.i64(); } - if (hasPlay) { - // S15/S16 play params, always present in a v3+ record (read in the emit order). + if (legacyV3Play) { + // LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D) + // were written as 44.1k-nominal frames -> divide by kLegacyV3NominalRate 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 (0.003 / 0 / 1.0 / 0.060). z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate; - z.play.adsr.holdFrames = r.i64(); + z.play.adsr.holdSeconds = static_cast(r.i64()) / kLegacyV3NominalRate; 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.attackFrames = r.i64(); - z.play.pitchEnv.decayFrames = r.i64(); + z.play.pitchEnv.attackSeconds = static_cast(r.i64()) / kLegacyV3NominalRate; + z.play.pitchEnv.decaySeconds = static_cast(r.i64()) / kLegacyV3NominalRate; 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()); } - if (hasAdsr) { - // S12 review fix (v4): the full per-zone A/D/S/R tail — read in the emit order. - // These values were authored at the DAW's rate; mark them resolved (no rescaling). - z.play.adsr.attackFrames = r.i64(); - z.play.adsr.decayFrames = r.i64(); - z.play.adsr.sustainLevel = bitsToDouble(r.u64()); - z.play.adsr.releaseFrames = r.i64(); - z.adsrNeedsRateResolve = false; // already rate-resolved; do NOT rescale at keymap build - } else if (hasPlay) { - // v3 blob: A/D/S/R fields are absent. Lift to the tier-0 nominal defaults (44100 Hz) - // so a voice playing this zone sounds bit-identical to the pre-v4 build (back-compat). - // Voice::start now uses the zone's full ADSR; a zone with these values reproduces - // the instrument-wide tier0Adsr behavior the pre-fix code applied unconditionally. - z.play.adsr.attackFrames = kTier0NominalAttackFrames; - z.play.adsr.decayFrames = kTier0NominalDecayFrames; - z.play.adsr.sustainLevel = kTier0NominalSustainLevel; - z.play.adsr.releaseFrames = kTier0NominalReleaseFrames; - } + // Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the + // seconds product defaults on z.play — a v4 blob cannot exist outside this branch. if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest map.zones.push_back(std::move(z)); } diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index 76edc4b..810999b 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -107,20 +107,50 @@ std::vector downmixToMono(const std::vector& interleav std::vector extractChannel(const std::vector& interleaved, int channelCount, int which); -// Nominal tier-0 ADSR defaults (frames at 44100 Hz) — used when lifting a pre-v4 zones payload -// blob whose per-zone A/D/S/R fields are absent, and as the initializer for new zones / the -// buildTier0Keymap default play arg. These are 44100-Hz nominal frame counts; buildTier0Keymap -// and buildZonedKeymap both rescale the A/D/R frame counts by (sampleRate / 44100) at build -// time when adsrNeedsRateResolve is set on the zone — so a v3-lifted or default zone plays with -// the same wall-clock ADSR as tier0Adsr(liveRate) did before the fix. Bit-identical at 44100 Hz. -// Zones loaded from a v4 blob (explicitly user-edited) carry adsrNeedsRateResolve=false and are -// never rescaled — their stored frame counts already reflect the rate at which they were authored. -// Must be declared before buildTier0Keymap (default arg) and PerformanceZone / ResolvedZone -// (member initializers) — both of which reference these values. -inline constexpr std::int64_t kTier0NominalAttackFrames = 132; // 0.003 * 44100, rounded -inline constexpr std::int64_t kTier0NominalDecayFrames = 0; -inline constexpr double kTier0NominalSustainLevel = 1.0; -inline constexpr std::int64_t kTier0NominalReleaseFrames = 2646; // 0.060 * 44100 +// --- Stored (wall-clock SECONDS) per-zone play params ------------------------- +// +// DOMAIN SPLIT (S12 remediation — Daniel's ruling: no hardcoded sample rate in the program). +// The instrument stores and edits WALL-CLOCK performance times as SECONDS, rate-free; the +// engine (sampler_core's ZonePlayParams, on SampleData) receives FRAMES resolved from the +// LIVE sample rate at keymap build. AHDSR (A/H/D/S/R) and the AD pitch envelope (attack/decay) +// are wall-clock — the voice advances them once per OUTPUT frame — so they live here in seconds. +// Quantities anchored to the source file's timeline (start point, loop points, Trigger %-length +// and its fades — the fades anchor to the source-frame read offset, PLAN.md §S15) stay in source +// frames / fractions and are carried through unchanged (TriggerParams is reused verbatim). +// +// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time. +struct AdsrSeconds { + double attackSeconds = 0.003; // tier-0 default + double holdSeconds = 0.0; + double decaySeconds = 0.0; + double sustainLevel = 1.0; + double releaseSeconds = 0.060; // tier-0 default +}; + +// The stored AD pitch-envelope times (seconds). enabled + peakSemitones are dimensionless. +struct PitchEnvSeconds { + bool enabled = false; + double attackSeconds = 0.0; + double decaySeconds = 0.0; + double peakSemitones = 0.0; // signed depth at the peak +}; + +// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities in +// frames/fractions (TriggerParams). This is the instrument-owned (D-B), serialized, editor-facing +// representation — distinct from sampler_core's engine-facing ZonePlayParams (frames). The keymap +// builders resolve this to a frame-domain ZonePlayParams against the live sample rate. +struct ZonePlaySeconds { + PlayMode playMode = PlayMode::Gate; + AdsrSeconds adsr; // Gate: AHDSR (seconds) + TriggerParams trigger; // Trigger: %-length + fades (source frames) + PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve (S16-F1) + PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default +}; + +// Resolve a stored seconds bundle to the engine's frame-domain ZonePlayParams against a live +// sample rate (frames = round(seconds * rate)). Source-timeline fields (trigger, engine, mode, +// peak, enabled) carry through unchanged. `sampleRate` must be > 0 (the caller guards this). +ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate); // Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole // keyboard, repitched from `rootNote`, looped per `loop`. The single-sample degenerate case @@ -129,20 +159,14 @@ inline constexpr std::int64_t kTier0NominalReleaseFrames = 2646; // 0.060 * 441 // which yields a mono SampleData byte-identical to the pre-S7 build. A `framesR` whose length // mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad // pair never half-plays. `sampleRate` is the WAV's rate. -// `play` carries the S15/S16 per-zone play params for the single-capture path; it defaults to -// the PRODUCT defaults (Gate + Preserve engine, S16-F1) so a picked single capture plays under -// the same default engine as a zone would. The A/D/R frame counts in the default play arg are -// 44100-Hz nominals; this function always rescales them by (sampleRate / 44100) before stamping -// them on the SampleData so the ADSR wall-clock durations match tier0Adsr(sampleRate) exactly. +// `play` carries the S15/S16 per-zone play params (SECONDS) for the single-capture path; it +// defaults to the PRODUCT defaults (Gate + tier-0 AHDSR seconds + Preserve engine, S16-F1) so a +// picked single capture plays under the same default engine as a zone would. This function +// resolves the wall-clock seconds to frames against `sampleRate` before stamping the SampleData. Keymap buildTier0Keymap(std::vector frames, int sampleRate, int rootNote, const SampleLoop& loop, std::vector framesR = {}, - const ZonePlayParams& play = ZonePlayParams{ - PlayMode::Gate, - AdsrParams{kTier0NominalAttackFrames, 0, - kTier0NominalDecayFrames, kTier0NominalSustainLevel, - kTier0NominalReleaseFrames}, - TriggerParams{}, kDefaultPitchEngine, PitchEnvParams{}}); + const ZonePlaySeconds& play = ZonePlaySeconds{}); // --- Performance map (Tier 1, D-B: the instrument's OWN state) --------------- // @@ -176,24 +200,12 @@ struct PerformanceZone { // S15/S16 per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch // engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the - // loop/start overrides. Defaults to the PRODUCT defaults for a NEW zone: Gate play mode, - // AHDSR with the tier-0 nominal A/D/S/R (kTier0Nominal* at 44100 Hz — the same values a - // v3 blob lifts to), hold 0, no fades, and the PRESERVE pitch engine (S16-F1), pitch env - // off. An older zone-payload blob (no S15/S16 tail or no v4 A/D/S/R tail) lifts to exactly - // these defaults on read (see the PAYLOAD v3/v4 versioning), so a pre-v4 instrument opens - // with Gate + Preserve + tier-0 ADSR — the deliberate back-compat path. - ZonePlayParams play{PlayMode::Gate, - AdsrParams{kTier0NominalAttackFrames, 0, - kTier0NominalDecayFrames, kTier0NominalSustainLevel, - kTier0NominalReleaseFrames}, - TriggerParams{}, kDefaultPitchEngine, PitchEnvParams{}}; - - // When true, the A/D/R frame counts in play.adsr are 44100-Hz nominals (either lifted from a - // v3 blob or defaulted for a new zone) that must be rescaled by (sampleRate / 44100) at keymap - // build time (buildZonedKeymap). Set to false when a v4 blob explicitly provides A/D/S/R (the - // stored counts already reflect the DAW rate at the time the user edited them) or when the user - // edits an ADSR slider (the committed value is already editor-domain). Never serialized. - bool adsrNeedsRateResolve = true; + // loop/start overrides. Wall-clock times are stored in SECONDS (rate-free); the keymap build + // resolves them to frames at the live sample rate. Defaults to the PRODUCT defaults for a NEW + // zone: Gate play mode, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades, + // PRESERVE pitch engine (S16-F1), pitch env off. An older zone-payload blob (no S15/S16 tail) + // lifts to exactly these defaults on read (see the PAYLOAD versioning). + ZonePlaySeconds play; }; // The instrument's performance map: an ordered list of zones. Order is authoritative for @@ -217,15 +229,7 @@ struct ResolvedZone { int rootNote = 60; // effective: override, else bank intrinsic, else 60 SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11) std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11) - ZonePlayParams play{PlayMode::Gate, - AdsrParams{kTier0NominalAttackFrames, 0, - kTier0NominalDecayFrames, kTier0NominalSustainLevel, - kTier0NominalReleaseFrames}, - TriggerParams{}, kDefaultPitchEngine, - PitchEnvParams{}}; // S15/S16 per-zone play params (carried through as-is) - // Carried from PerformanceZone::adsrNeedsRateResolve — buildZonedKeymap rescales A/D/R - // frames by (sampleRate / 44100) when true. False for v4-explicit or user-edited values. - bool adsrNeedsRateResolve = true; + ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build) }; // The result of resolving a performance map against the live bank blob. `zones` are the @@ -305,27 +309,39 @@ DecodedZonePcm decodeChannels(const std::vector& interleaved, // 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64). // The reader detects the marker to know the record shape — a v1 payload (no marker) reads // the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope. -// * PAYLOAD v3 (S15/S16): the same marker + payload version (== 3), THEN the v2 body PLUS, -// appended to each zone record after the S11 startPoint tail (the S15/S16 per-zone play -// params — always present, NOT flag-gated, since every zone has a play mode + engine): +// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload +// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint +// tail (the S15/S16 per-zone play params — always present, NOT flag-gated): // 1 byte playMode (0 = Gate, 1 = Trigger); -// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage; +// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal; // 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE); // 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); // 1 byte pitchEngine (0 = Varispeed, 1 = Preserve); -// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64); -// 8-byte LE pitchEnv.decayFrames (int64); 8-byte LE pitchEnv.peakSemitones as a double. +// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom); +// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double. // A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve + // no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved // instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest. -// * PAYLOAD v4 (S12 review fix): the same marker + payload version (== 4), THEN the v3 body -// PLUS, appended to each zone record after the v3 pitch-env tail, the full per-zone A/D/S/R: -// 8-byte LE adsr.attackFrames (int64); 8-byte LE adsr.decayFrames (int64); -// 8-byte LE adsr.sustainLevel as an IEEE-754 double (bit-cast to u64 LE); -// 8-byte LE adsr.releaseFrames (int64). -// A v3 payload (no v4 A/D/S/R tail) lifts those fields to the tier-0 nominal defaults at -// 44100 Hz (kTier0Nominal* constants) so a voice using the zone ADSR sounds bit-identical to -// the pre-v4 build. Voice::start now uses the zone's full ADSR for all five AHDSR fields. +// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS +// written by the S15/S16 editor as 44.1k-nominal frames (that build's slider domain was fixed at +// 44100), so they convert to the seconds domain by dividing by that authoring-time nominal rate +// (kLegacyV3NominalRate). 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), no rate. +// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5), +// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full +// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles): +// 1 byte playMode (0 = Gate, 1 = Trigger); +// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double); +// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); +// 1 byte pitchEngine; 1 byte pitchEnv.enabled; +// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double); +// 8-byte LE pitchEnv.peakSemitones (double); +// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double); +// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double). +// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4 +// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader +// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds +// to frames at the LIVE sample rate; no rate is baked into storage or the program. // BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is // lifted to a single full-keyboard zone playing that id (no override) — so an instance saved // under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes @@ -345,9 +361,15 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2; // (marker + version 2, no play tail) for back-compat, lifting the missing fields to defaults. // The marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in // practice, always tiny) can never collide with. -inline constexpr std::uint32_t kZonesPayloadVersion = 4; // S15/S16 A/D/S/R per-zone tail +inline constexpr std::uint32_t kZonesPayloadVersion = 5; // S12: full per-zone play params, SECONDS inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; +// The authoring-time nominal rate the LEGACY v3 zone payload's wall-clock frame counts (hold, +// pitchEnv A/D) were always written at (the S15/S16 editor's slider domain was fixed at 44100 Hz). +// Used ONLY at the v3 read boundary to convert those legacy frames to the seconds domain — it is a +// property of the frozen v3 wire format, not a live program rate. No other site may reference it. +inline constexpr double kLegacyV3NominalRate = 44100.0; + // The performance map serialized to bytes for IBStream (getState). std::vector serializePerformance(const PerformanceMap& map); @@ -364,12 +386,13 @@ PerformanceMap deserializePerformance(const std::vector& bytes); // instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty // 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), -// then a 4-byte LE selection-id length + id bytes, then the v2 zones payload (4-byte LE zone -// count + per-zone records, identical to serializePerformance's body). The 8-byte marker is -// the ONLY v5 addition over v4 — the envelope grew a field, the zones payload is untouched -// (a PARALLEL track owns zone-record extension under the map's own versioning). BACK-COMPAT on +// then a 4-byte LE selection-id length + id bytes, then the CURRENT zones payload (identical to +// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block). +// The 8-byte marker is the ONLY envelope-v5 addition over envelope-v4 — the envelope grew a field, +// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own +// versioning; the two version numbers are independent axes). BACK-COMPAT on // read (every older blob lifts to channelMode = MONO and lastConsumedAssignGeneration = 0, // preserving current behavior for already-saved instances): // * v5 blob -> {channelMode, lastConsumedAssignGeneration, selectionId, zones} direct. diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 82734fe..afb3361 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -249,8 +249,7 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) { shiftR_.configure(windowFrames); } -void Voice::start(int note, int velocity, const SampleData& sample, int rootNote, - const AdsrParams& gateAdsr) { +void Voice::start(int note, int velocity, const SampleData& sample, int rootNote) { active_ = true; releasing_ = false; amplitudeDone_ = false; @@ -279,15 +278,13 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote // --- Amplitude envelope: Gate = AHDSR (fully per-zone: A/H/D/S/R all read from the zone's // play.adsr); Trigger = the time-boxed fade-in/out over the % play length. // - // All five AHDSR fields come from sample.play.adsr, stamped by buildTier0Keymap / - // buildZonedKeymap at reload time (with rate-rescaling for v3-lifted / default zones). - // gateAdsr (the VoiceEngine's instrument-wide ADSR) is accepted for interface compat - // but is NOT read here — it is vestigial since the S12 review fix moved A/D/S/R fully - // onto the per-zone SampleData. + // 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 adsr fields carry the tier-0 nominal values - // (rescaled to the live sample rate by buildZonedKeymap) sounds bit-identical to the - // pre-fix build at every DAW rate. --- + // Back-compat invariant: a zone whose stored ADSR seconds carry the tier-0 defaults + // (resolved to frames at the live sample rate) sounds identical to the pre-S12 build at + // every DAW rate — now trivially true, since the times are wall-clock seconds. --- if (playMode_ == PlayMode::Gate) { env_.configure(p.adsr); env_.noteOn(); @@ -488,9 +485,9 @@ void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) { // --------------------------------------------------------------------------- VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap, - const AdsrParams& adsr, std::size_t preserveVoiceCap, + std::size_t preserveVoiceCap, std::int64_t preserveWindowFrames) - : voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap), adsr_(adsr), + : voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap), preserveVoiceCap_(preserveVoiceCap) { // maxVoices == 0 would mean "no polyphony at all", which cannot service a note-on; // clamp to a single voice so the engine is always usable (documented degenerate). @@ -561,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 // start() only reset()s + warm()s them — no allocation on this audio-thread path. const std::size_t v = allocateVoice(); - voices_[v].start(note, velocity, sample, zone.rootNote, adsr_); + voices_[v].start(note, velocity, sample, zone.rootNote); voices_[v].setStartOrder(nextStartOrder_++); return v; } diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index e552190..98f826b 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -341,18 +341,15 @@ public: // Starts this voice on `note` at `velocity`, playing `sample` (a stable reference // the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched // from `rootNote`. All five AHDSR fields (A/H/D/S/R) are read directly from - // sample.play.adsr — the per-zone values stamped by buildTier0Keymap / buildZonedKeymap. - // `gateAdsr` is the VoiceEngine's instrument-wide ADSR parameter, accepted for interface - // compatibility but NOT used by start() (vestigial since the S12 review fix moved A/D/S/R - // onto the per-zone SampleData). The S15 play MODE + Trigger params and the S16 pitch - // ENGINE + pitch envelope are read from `sample.play`. The Preserve shifters MUST already - // be pre-sized (presizePreserveShifters, off-thread) — start() only reset()s + warm()s - // them (RT-safe, no allocation) since it runs on the audio thread inside process(). The warm - // silence pass settles the OLA taps before the first output frame (no cold-start click). - // Byte-identical to the pre-S15 engine when sample.play is default (Gate + Varispeed + no - // pitch env). - void start(int note, int velocity, const SampleData& sample, int rootNote, - const AdsrParams& gateAdsr); + // sample.play.adsr — the per-zone values (in FRAMES) resolved from the stored seconds by + // buildTier0Keymap / buildZonedKeymap against the live sample rate. The S15 play MODE + + // Trigger params and the S16 pitch ENGINE + pitch envelope are read from `sample.play`. + // The Preserve shifters MUST already be pre-sized (presizePreserveShifters, off-thread) — + // start() only reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio + // thread inside process(). The warm silence pass settles the OLA taps before the first + // output frame (no cold-start click). Byte-identical to the pre-S15 engine when sample.play + // is default (Gate + Varispeed + no pitch env). + void start(int note, int velocity, const SampleData& sample, int rootNote); // Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in // TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length). @@ -456,18 +453,19 @@ class VoiceEngine { public: // Builds an engine with `maxVoices` voices (the polyphony bound) playing from // `keymap`. The keymap must outlive the engine (the engine holds a reference — it - // reads zones and sample data through it, never copies PCM). `adsr` is the instrument-wide - // Gate AHDSR timing (attack/decay/sustain/release); each zone's HOLD stage + play mode + - // pitch engine ride on its SampleData::play. `preserveVoiceCap` (S16) bounds how many - // Preserve-engine voices may sound at once (the shifter is materially heavier than - // Varispeed) — a Preserve note-on beyond the cap is dropped rather than glitching; 0 means - // "no separate Preserve cap" (bounded only by maxVoices). `preserveWindowFrames` is the OLA - // window (in OUTPUT frames) every voice's Preserve pitch shifters are PRE-SIZED to at - // construction (OFF the audio thread), so note-on (which runs in process()) never allocates; - // 0 leaves them pass-through (a Varispeed-only instrument pays no ring cost). The processor - // derives it from the host sample rate (kPreserveWindowMs). Defaulted so existing callers - // (and the pure-core tests) are unaffected. - VoiceEngine(std::size_t maxVoices, const Keymap& keymap, const AdsrParams& adsr, + // reads zones and sample data through it, never copies PCM). Every AHDSR field (A/H/D/S/R) + // + play mode + pitch engine rides on each zone's SampleData::play (in FRAMES, resolved + // from the stored seconds at keymap build); the engine holds no instrument-wide ADSR. + // `preserveVoiceCap` (S16) bounds how many Preserve-engine voices may sound at once (the + // shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is + // dropped rather than glitching; 0 means "no separate Preserve cap" (bounded only by + // maxVoices). `preserveWindowFrames` is the OLA window (in OUTPUT frames) every voice's + // Preserve pitch shifters are PRE-SIZED to at construction (OFF the audio thread), so + // note-on (which runs in process()) never allocates; 0 leaves them pass-through (a + // Varispeed-only instrument pays no ring cost). The processor derives it from the host + // sample rate (kPreserveWindowMs). Defaulted so existing callers (and the pure-core tests) + // are unaffected. + VoiceEngine(std::size_t maxVoices, const Keymap& keymap, std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0); // MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of @@ -524,7 +522,6 @@ private: std::vector voices_; const Keymap& keymap_; - AdsrParams adsr_; 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" }; diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 339972d..73609a2 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -1031,37 +1031,157 @@ static void testComponentStateV5TruncatedMarker() { 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)); + CHECK(back.channelMode == ChannelMode::Stereo); // envelope: mode + CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // envelope: marker + CHECK(back.selectionId == "pick"); + CHECK(back.map.zones.size() == 1); + if (back.map.zones.size() != 1) return; + const ZonePlaySeconds& p = back.map.zones[0].play; // payload: play params + CHECK(p.playMode == PlayMode::Trigger); + CHECK(p.adsr.holdSeconds == 0.093); + CHECK(p.trigger.lengthFraction == 0.625); + CHECK(p.trigger.fadeInFrames == 32 && p.trigger.fadeOutFrames == 96); + CHECK(p.pitchEngine == PitchEngine::Varispeed); + CHECK(p.pitchEnv.enabled && p.pitchEnv.attackSeconds == 0.00018 && + p.pitchEnv.decaySeconds == 0.0145 && p.pitchEnv.peakSemitones == 12.5); +} + +// Hand-build ONE v3 zone record (marker-versioned payload body) for a single-zone map. Emits the +// exact on-wire order the header's PAYLOAD v3 spec + putZonesPayload write: id, lo/hi, no root/loop/ +// start overrides, then the always-present S15/S16 play tail. Used to synthesize the two v4 blobs +// below WITHOUT serializeComponentState (which now emits v5) — so the reader's widened accept-chain +// is exercised against a genuine, older-envelope byte layout rather than a self-produced buffer. +static std::vector handBuildV3PayloadOneZone(const std::string& id) { + std::vector b; + auto u32 = [&](std::uint32_t v) { + b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF); + b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF); + }; + auto u64 = [&](std::uint64_t v) { + for (int i = 0; i < 8; ++i) b.push_back(static_cast((v >> (i * 8)) & 0xFF)); + }; + auto dbl = [&](double d) { std::uint64_t bits; std::memcpy(&bits, &d, 8); u64(bits); }; + u32(kZonesFormatMarker); + u32(3); // PAYLOAD VERSION 3 (S15/S16 play tail present) + u32(1); // zone count 1 + u32(static_cast(id.size())); + b.insert(b.end(), id.begin(), id.end()); + u32(10); // lowNote + u32(70); // highNote + b.push_back(0); // hasRootOverride = 0 + b.push_back(0); // hasLoopOverride = 0 + b.push_back(0); // hasStartPoint = 0 + // Always-present v3 play tail: Trigger, hold, lengthFraction, fades, Varispeed, env off. + b.push_back(1); // playMode = Trigger + u64(static_cast(2048)); // adsr.holdFrames + dbl(0.5); // trigger.lengthFraction + u64(static_cast(16)); // trigger.fadeInFrames + u64(static_cast(48)); // trigger.fadeOutFrames + b.push_back(0); // pitchEngine = Varispeed + b.push_back(0); // pitchEnv.enabled = 0 + u64(0); // pitchEnv.attackFrames + u64(0); // pitchEnv.decayFrames + dbl(0.0); // pitchEnv.peakSemitones + return b; +} + +static void testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay() { + // (b) + (c) unified: a GENUINE v4 ENVELOPE blob (version tag 4: mode byte, id, then the zones + // payload — NO 8-byte marker) whose zones payload is PAYLOAD v3 (the exact shape an S15-test-build + // save produced). Under the widened accept-chain it must (b) lift lastConsumedAssignGeneration to + // 0 AND (c) deserialize its payload-v3 play params intact. This is the precise blob a user who + // saved on the S15 test build (envelope v4 + payload v3) would hold; the v4 lift branch delegates + // zones to readZonesPayload, which self-selects the v3 record shape from the payload marker — so + // the two v4 layouts (S7-era payload-v2, S15-era payload-v3) are UNAMBIGUOUS, distinguished + // inside the payload, not on the envelope. + std::vector v4; + v4.push_back(4); v4.push_back(0); v4.push_back(0); v4.push_back(0); // ENVELOPE version 4 + v4.push_back(1); // channel mode = stereo + const std::string id = "s15saved"; + v4.push_back(static_cast(id.size())); + v4.push_back(0); v4.push_back(0); v4.push_back(0); // idLen (LE) + v4.insert(v4.end(), id.begin(), id.end()); + const std::vector payload = handBuildV3PayloadOneZone("zv3"); + v4.insert(v4.end(), payload.begin(), payload.end()); + + const ComponentState back = deserializeComponentState(v4); + 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 (44.1k-nominal) convert to seconds at the v3 authoring rate. + CHECK(approx(p.adsr.holdSeconds, 2048.0 / kLegacyV3NominalRate)); + CHECK(p.trigger.lengthFraction == 0.5); + CHECK(p.trigger.fadeInFrames == 16 && p.trigger.fadeOutFrames == 48); // source frames, as-is + CHECK(p.pitchEngine == PitchEngine::Varispeed); + CHECK(p.pitchEnv.enabled == false); +} + // --- S15/S16 zone-payload v3: per-zone play params round-trip + back-compat lift ------------- static void testPlayParamsRoundTrip() { - // A zone carrying explicit S15/S16 play params (Trigger mode, hold, fades, Varispeed engine, - // pitch env on) must round-trip ALL fields losslessly through the payload-v3 tail. + // A zone carrying explicit S15/S16 play params (Trigger mode, hold seconds, source-frame fades, + // Varispeed engine, pitch env on) must round-trip ALL fields losslessly through the v5 tail. PerformanceMap m; PerformanceZone z = zone("lead", 20, 100, /*override=*/55); z.play.playMode = PlayMode::Trigger; - z.play.adsr.holdFrames = 1234; + z.play.adsr.holdSeconds = 0.028; // wall-clock seconds 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.pitchEngine = PitchEngine::Varispeed; z.play.pitchEnv.enabled = true; - z.play.pitchEnv.attackFrames = 10; - z.play.pitchEnv.decayFrames = 500; + z.play.pitchEnv.attackSeconds = 0.0002; // wall-clock seconds + z.play.pitchEnv.decaySeconds = 0.011; z.play.pitchEnv.peakSemitones = -7.5; m.zones.push_back(z); const PerformanceMap back = deserializePerformance(serializePerformance(m)); CHECK(back.zones.size() == 1); 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.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.fadeInFrames == 64); CHECK(p.trigger.fadeOutFrames == 128); CHECK(p.pitchEngine == PitchEngine::Varispeed); CHECK(p.pitchEnv.enabled == true); - CHECK(p.pitchEnv.attackFrames == 10); - CHECK(p.pitchEnv.decayFrames == 500); + CHECK(p.pitchEnv.attackSeconds == 0.0002); + CHECK(p.pitchEnv.decaySeconds == 0.011); CHECK(p.pitchEnv.peakSemitones == -7.5); // exact double round-trip } @@ -1073,7 +1193,7 @@ static void testPlayParamsComposeWithLoopStart() { z.loopOverride = lp; z.startPoint = 333; z.play.playMode = PlayMode::Gate; - z.play.adsr.holdFrames = 999; + z.play.adsr.holdSeconds = 0.0225; z.play.pitchEngine = PitchEngine::Preserve; m.zones.push_back(z); const PerformanceMap back = deserializePerformance(serializePerformance(m)); @@ -1082,7 +1202,7 @@ static void testPlayParamsComposeWithLoopStart() { CHECK(back.zones[0].loopOverride.has_value() && back.zones[0].loopOverride->start == 111 && back.zones[0].loopOverride->end == 222); CHECK(back.zones[0].startPoint.has_value() && *back.zones[0].startPoint == 333); - CHECK(back.zones[0].play.adsr.holdFrames == 999); + CHECK(back.zones[0].play.adsr.holdSeconds == 0.0225); CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve); } @@ -1115,7 +1235,7 @@ static void testPlayParamsV2BackCompatLiftsToDefaults() { CHECK(back.zones[0].play.playMode == PlayMode::Gate); CHECK(back.zones[0].play.pitchEngine == kDefaultPitchEngine); // == Preserve CHECK(back.zones[0].play.pitchEnv.enabled == false); - CHECK(back.zones[0].play.adsr.holdFrames == 0); + CHECK(back.zones[0].play.adsr.holdSeconds == 0.0); } static void testPlayParamsThroughComponentEnvelope() { @@ -1138,44 +1258,40 @@ static void testPlayParamsThroughComponentEnvelope() { CHECK(back.map.zones[0].play.pitchEngine == PitchEngine::Varispeed); } -// --- S12 review fix (PAYLOAD v4): full A/D/S/R per-zone round-trip. --------------------- +// --- S12 domain fix: wall-clock ADSR stored as SECONDS, resolved to frames at the live rate. --- // -// Before the fix, per-zone A/D/S/R (attack/decay/sustain/release) was not serialized; -// only holdFrames was written. These two tests assert the corrected v4 path. +// 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 (including the four new A/D/S/R) must round-trip through the v4 payload. -static void testFullAdsrV4RoundTrip() { +// 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.attackFrames = 441; // 0.01 s at 44100 Hz (a non-default value) - z.play.adsr.holdFrames = 882; - z.play.adsr.decayFrames = 4410; // 0.1 s - z.play.adsr.sustainLevel = 0.7; - z.play.adsr.releaseFrames = 8820; // 0.2 s + 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)); CHECK(back.zones.size() == 1); if (back.zones.size() != 1) return; - const AdsrParams& a = back.zones[0].play.adsr; - CHECK(a.attackFrames == 441); - CHECK(a.holdFrames == 882); - CHECK(a.decayFrames == 4410); - CHECK(a.sustainLevel == 0.7); // exact double round-trip via bit-cast - CHECK(a.releaseFrames == 8820); + 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 genuine PAYLOAD v3 blob (S15/S16 build — has holdFrames but not A/D/S/R) must lift -// attackFrames / decayFrames / sustainLevel / releaseFrames to the tier-0 nominal defaults -// (kTier0Nominal* constants) so the voice sounds bit-identical to the pre-fix behavior. -// We reuse handBuildV3PayloadOneZone which emits a valid marker-versioned v3 payload. -static void testV3BlobLiftsAdsrToNominalDefaults() { - // Build a PERFORMANCE blob: 4-byte kPerformanceStateVersion header + v3 zones payload. - // deserializePerformance strips the 4-byte header and passes the rest to readZonesPayload, - // which self-selects the v3 record shape from the payload marker+version — exercising the - // real production lift path for a user who saved on the S15 build. +// A legacy PAYLOAD v3 blob (Daniel's beta projects — has holdFrames but no A/D/S/R) lifts the +// absent A/D/S/R to the tier-0 SECONDS defaults (0.003 / 0 / 1.0 / 0.060), NO rate involved: they +// were always the seconds constants. holdSeconds converts from the v3 44.1k-nominal frame count. +static void testV3BlobLiftsAdsrToSecondsDefaults() { std::vector blob; auto u32 = [&](std::uint32_t v) { blob.push_back(v & 0xFF); blob.push_back((v >> 8) & 0xFF); @@ -1187,91 +1303,81 @@ static void testV3BlobLiftsAdsrToNominalDefaults() { const PerformanceMap back = deserializePerformance(blob); CHECK(back.zones.size() == 1); if (back.zones.size() != 1) return; - const AdsrParams& a = back.zones[0].play.adsr; - // holdFrames comes from the v3 record itself; A/D/S/R must lift to the nominal tier-0 values. - CHECK(a.holdFrames == 2048); // from the hand-built v3 record - CHECK(a.attackFrames == kTier0NominalAttackFrames); // 132 (0.003 s at 44100) - CHECK(a.decayFrames == kTier0NominalDecayFrames); // 0 - CHECK(a.sustainLevel == kTier0NominalSustainLevel); // 1.0 - CHECK(a.releaseFrames == kTier0NominalReleaseFrames); // 2646 (0.060 s at 44100) - // The lifted zone must be flagged for rate-resolve so buildZonedKeymap rescales at the live rate. - CHECK(back.zones[0].adsrNeedsRateResolve == true); + const AdsrSeconds& a = back.zones[0].play.adsr; + // hold converts from the v3 record's 44.1k-nominal frames; A/D/S/R lift to the seconds defaults. + CHECK(approx(a.holdSeconds, 2048.0 / kLegacyV3NominalRate)); // 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 v4 blob (explicit A/D/S/R tail) must NOT set adsrNeedsRateResolve — those values -// were authored at the DAW's rate and must not be rescaled again at keymap build time. -static void testV4BlobClearsAdsrNeedsRateResolve() { - PerformanceMap m; - PerformanceZone z = zone("pad", 0, 127); - z.play.adsr.attackFrames = 441; - z.play.adsr.releaseFrames = 8820; - m.zones.push_back(z); - // A round-trip through serialize/deserialize writes a v4 payload (current version). - const PerformanceMap back = deserializePerformance(serializePerformance(m)); - CHECK(back.zones.size() == 1); - if (back.zones.size() != 1) return; - // v4 tail was explicitly read — adsrNeedsRateResolve must be false. - CHECK(back.zones[0].adsrNeedsRateResolve == false); +// The lift -> commit -> reload sequence must stay rate-correct at 44.1k / 48k / 96k. A DEFAULT zone +// resolves to the tier-0 wall-clock durations at each rate (round(0.003*rate), round(0.060*rate)); +// an AUTHORED zone resolves to round(seconds*rate). This is the R2 blocker, pinned across rates. +static void testKeymapBuildResolvesSecondsToFramesAtEachRate() { + const auto rnd = [](double s, int rate) { + return static_cast(s * static_cast(rate) + 0.5); + }; + for (int rate : {44100, 48000, 96000}) { + // (a) DEFAULT zone (round-tripped through serialize/deserialize) -> tier-0 seconds. + { + PerformanceMap m; + m.zones.push_back(zone("def", 0, 127)); // product-default play (tier-0 AHDSR seconds) + const PerformanceMap back = deserializePerformance(serializePerformance(m)); + 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)); + 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 at 48k must produce ADSR frame counts equal to tier0Adsr(48000): -// attack = round(kTier0NominalAttackFrames * 48000 / 44100) = round(143.67) = 144, -// release = round(kTier0NominalReleaseFrames * 48000 / 44100) = round(2880.0) = 2880. -// This is the "pre-fix, the Gate voice used tier0Adsr(sampleRate_)" invariant restored -// for the single-capture fast path at any DAW rate. -static void testBuildTier0KeymapRescalesAdsrAt48k() { +// 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; - // Rescaled from 44100-nominal at 48000 Hz: - CHECK(a.attackFrames == 144); // round(132 * 48000.0 / 44100.0) - CHECK(a.decayFrames == 0); // 0 * factor = 0 (no change) - CHECK(a.sustainLevel == 1.0); // level, not frames (no rescale) - CHECK(a.releaseFrames == 2880); // round(2646 * 48000.0 / 44100.0) -} - -// buildZonedKeymap at 48k with adsrNeedsRateResolve=true must rescale ADSR to match -// tier0Adsr(48000), mirroring what Gate voices saw before the per-zone-ADSR fix. -static void testBuildZonedKeymapRescalesNominalAdsrAt48k() { - // Build a zone with nominal 44100-Hz ADSR and the needs-resolve flag (the default). - ResolvedZone z; - z.lowNote = 0; z.highNote = 127; z.rootNote = 60; - z.adsrNeedsRateResolve = true; // 44100-nominal values, rescale needed - z.play.adsr.attackFrames = kTier0NominalAttackFrames; // 132 - z.play.adsr.decayFrames = kTier0NominalDecayFrames; // 0 - z.play.adsr.sustainLevel = kTier0NominalSustainLevel; // 1.0 - z.play.adsr.releaseFrames = kTier0NominalReleaseFrames; // 2646 - const DecodedZonePcm pcm{{0.5f}, 48000}; // 48k WAV - const Keymap km = buildZonedKeymap({z}, {pcm}); - CHECK(km.samples.size() == 1); - if (km.samples.empty()) return; - const AdsrParams& a = km.samples[0].play.adsr; - CHECK(a.attackFrames == 144); // round(132 * 48000.0 / 44100.0) - CHECK(a.decayFrames == 0); // 0 * factor = 0 - CHECK(a.sustainLevel == 1.0); // level, not rescaled - CHECK(a.releaseFrames == 2880); // round(2646 * 48000.0 / 44100.0) -} - -// buildZonedKeymap must NOT rescale a zone whose adsrNeedsRateResolve is false — -// those frame counts are explicitly user-authored at the DAW's rate. -static void testBuildZonedKeymapDoesNotRescaleV4Adsr() { - ResolvedZone z; - z.lowNote = 0; z.highNote = 127; z.rootNote = 60; - z.adsrNeedsRateResolve = false; // v4 blob or user-edited — do not rescale - z.play.adsr.attackFrames = 441; // 0.01 s at 44100 Hz (non-nominal) - z.play.adsr.decayFrames = 4410; - z.play.adsr.sustainLevel = 0.7; - z.play.adsr.releaseFrames = 8820; - const DecodedZonePcm pcm{{0.5f}, 48000}; // 48k WAV — rescale would change values - const Keymap km = buildZonedKeymap({z}, {pcm}); - CHECK(km.samples.size() == 1); - if (km.samples.empty()) return; - const AdsrParams& a = km.samples[0].play.adsr; - CHECK(a.attackFrames == 441); // unchanged (not rescaled) - CHECK(a.decayFrames == 4410); - CHECK(a.sustainLevel == 0.7); - CHECK(a.releaseFrames == 8820); + 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() { @@ -1325,12 +1431,10 @@ int main() { testPlayParamsComposeWithLoopStart(); testPlayParamsV2BackCompatLiftsToDefaults(); testPlayParamsThroughComponentEnvelope(); - testFullAdsrV4RoundTrip(); - testV3BlobLiftsAdsrToNominalDefaults(); - testV4BlobClearsAdsrNeedsRateResolve(); - testBuildTier0KeymapRescalesAdsrAt48k(); - testBuildZonedKeymapRescalesNominalAdsrAt48k(); - testBuildZonedKeymapDoesNotRescaleV4Adsr(); + testFullAdsrSecondsRoundTrip(); + testV3BlobLiftsAdsrToSecondsDefaults(); + testKeymapBuildResolvesSecondsToFramesAtEachRate(); + testBuildTier0KeymapResolvesSecondsAt48k(); testComponentStateRoundTrip(); testComponentStateLoopStartRoundTrip(); testComponentStateSelectionOnlyNoZones(); @@ -1358,6 +1462,8 @@ int main() { testComponentStateDefaultMarkerIsZero(); testComponentStateV4LiftsMarkerToZero(); testComponentStateV5TruncatedMarker(); + testV5EnvelopeWithMarkerAndPlayParamsRoundTrip(); + testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay(); if (g_fail == 0) std::printf("sample_map: all tests passed\n"); return g_fail != 0; diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 7622d22..66153b2 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -149,7 +149,7 @@ static void testRepitchObservedPeriod() { // Unity: played at root, observed period ~= native. { Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60)); - VoiceEngine eng(4, km, flatAdsr()); + VoiceEngine eng(4, km); eng.noteOn(60, 127); std::vector out; eng.render(out, frames); @@ -159,7 +159,7 @@ static void testRepitchObservedPeriod() { // +1 octave: advances 2x, observed period halves. { Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60)); - VoiceEngine eng(4, km, flatAdsr()); + VoiceEngine eng(4, km); eng.noteOn(72, 127); std::vector out; 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. { Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60)); - VoiceEngine eng(4, km, flatAdsr()); + VoiceEngine eng(4, km); eng.noteOn(48, 127); std::vector out; eng.render(out, frames); @@ -285,7 +285,7 @@ static void testAdsrZeroAttackDecay() { static void testPolyphonicAllocation() { 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. 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 gainNew = velNew / 127.0; // 1.0 - Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60)); - AdsrParams a = flatAdsr(); - a.releaseFrames = 10; // short but non-zero so voice stays active through release - VoiceEngine eng(8, km, a); + SampleData sd = dcSample(100000, 60); + sd.play.adsr = flatAdsr(); + sd.play.adsr.releaseFrames = 10; // short but non-zero so voice stays active through release + Keymap km = Keymap::singleSampleChromatic(sd); + VoiceEngine eng(8, km); std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain std::size_t second = eng.noteOn(60, velNew); // newer voice, higher gain @@ -371,7 +372,7 @@ static void testOutOfZoneNoteConsumesNoVoice() { Keymap km; km.samples.push_back(dcSample(100, 60)); km.zones.push_back(KeyZone{60, 72, 60, 0}); - VoiceEngine eng(4, km, flatAdsr()); + VoiceEngine eng(4, km); std::size_t v = eng.noteOn(30, 100); // below the only zone CHECK(v == VoiceEngine::kNoVoice); @@ -383,14 +384,13 @@ static void testOutOfZoneNoteConsumesNoVoice() { // --------------------------------------------------------------------------- static void testStealsReleasingVoiceFirst() { - // Long per-zone release so the voice stays active through the release tail. - // Per the S12 fix, Voice::start uses sample.play.adsr — not the engine's gateAdsr — - // so the long release must live on the SampleData, not on the VoiceEngine constructor arg. + // Long per-zone release so the voice stays active through the release tail. Voice::start reads + // sample.play.adsr (the engine holds no ADSR), so the long release lives on the SampleData. SampleData s = dcSample(100000, 60); s.play.adsr = flatAdsr(); s.play.adsr.releaseFrames = 100000; // long release so a released voice stays "active" Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(2, km, flatAdsr()); + VoiceEngine eng(2, km); std::size_t vA = eng.noteOn(60, 100); // startOrder 1 std::size_t vB = eng.noteOn(62, 100); // startOrder 2 @@ -415,7 +415,7 @@ static void testStealsOldestWhenNoneReleasing() { s.play.adsr = flatAdsr(); s.play.adsr.releaseFrames = 100000; Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(2, km, flatAdsr()); + VoiceEngine eng(2, km); std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest) std::size_t vB = eng.noteOn(62, 100); // startOrder 2 @@ -453,7 +453,7 @@ static void testLoopSustainSeamless() { s.loop.end = 40; Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); // unity ratio, full velocity std::vector out; @@ -475,7 +475,7 @@ static void testZeroLengthLoopGoesSilent() { s.loop.start = 25; s.loop.end = 25; // zero length Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; @@ -499,7 +499,7 @@ static void testSingleFrameLoop() { s.loop.end = 6; // single-frame loop: [5, 6) Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); // unity ratio, full velocity std::vector out; @@ -518,7 +518,7 @@ static void testAbsentLoopGoesSilent() { SampleData s = dcSample(50, 60); // s.loop.hasLoop stays false. Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 100); @@ -539,7 +539,7 @@ static void testStartFrameOffsetsInitialRead() { s.rootNote = 60; s.startFrame = 30; 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 std::vector out; eng.render(out, 3); @@ -555,7 +555,7 @@ static void testStartFrameZeroIsUnchanged() { for (int i = 0; i < 20; ++i) s.frames[i] = static_cast(i) * 0.05f; s.rootNote = 60; // startFrame stays 0 Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 1); @@ -568,7 +568,7 @@ static void testStartFrameOutOfRangeClampsToZero() { SampleData s = dcSample(10, 60); // 10 frames of 1.0 s.startFrame = 10; // == frameCount: out of range Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 5); @@ -589,7 +589,7 @@ static void testStartFrameWithLoop() { s.loop.start = 20; s.loop.end = 40; Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 200); @@ -618,7 +618,7 @@ static void testStartAfterLoopEndWrapsIntoLoop() { s.loop.end = 40; Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); // unity ratio, full velocity std::vector out; @@ -644,21 +644,21 @@ static void testVelocityToVolume() { // Full velocity -> full gain; half velocity -> ~half gain (flat envelope so the // rendered value is exactly velocity/127 on a DC-1 sample). { - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 1); CHECK(approx(out[0], 1.0, 1e-4)); } { - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 64); std::vector out; eng.render(out, 1); CHECK(approx(out[0], 64.0 / 127.0, 1e-4)); } { - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 1); std::vector out; eng.render(out, 1); @@ -669,7 +669,7 @@ static void testVelocityToVolume() { // Two voices summed: polyphony mixes additively. static void testPolyphonyMixesAdditively() { 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 (second voice, same note) std::vector out; @@ -705,7 +705,7 @@ static void testStereoRenderKeepsChannelsDistinct() { // A stereo sample (L=1.0, R=-1.0) rendered stereo must emit L and R distinctly, each // scaled by velocity (full here). If the engine copied L to both channels the R check fails. Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector left(8, 0.f), right(8, 0.f); @@ -720,7 +720,7 @@ static void testMonoSamplePlaysDualMonoInStereo() { // A MONO sample rendered through the stereo path plays dual-mono: both channels equal // (centered), not silent on the right. The cross-mode "mono source in stereo mode" case. Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // mono, DC 1.0 - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector left(8, 0.f), right(8, 0.f); eng.render(left.data(), right.data(), 8); @@ -734,7 +734,7 @@ static void testMonoRenderUnchangedByStereoData() { // Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical // whether or not a second channel is present. A stereo sample rendered mono == its L channel. Keymap kmS = Keymap::singleSampleChromatic(stereoDcSample(100, 0.75f, -0.25f, 60)); - VoiceEngine engS(1, kmS, flatAdsr()); + VoiceEngine engS(1, kmS); engS.noteOn(60, 127); std::vector mono; engS.render(mono, 8); // the mono overload @@ -759,7 +759,7 @@ static void testStereoRenderAdvancesLikeMonoRepitch() { } s.rootNote = 60; Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(4, km, flatAdsr()); + VoiceEngine eng(4, km); eng.noteOn(72, 127); // +1 octave std::vector left(frames / 2, 0.f), right(frames / 2, 0.f); eng.render(left.data(), right.data(), frames / 2); @@ -770,7 +770,7 @@ static void testStereoRenderAdvancesLikeMonoRepitch() { static void testStereoRenderSumsVoicesPerChannel() { // Two voices on a stereo sample sum PER CHANNEL (additive polyphony holds in stereo). Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 0.5f, -0.5f, 60)); - VoiceEngine eng(4, km, flatAdsr()); + VoiceEngine eng(4, km); eng.noteOn(60, 127); eng.noteOn(60, 127); // second voice, same note std::vector left(1, 0.f), right(1, 0.f); @@ -781,7 +781,7 @@ static void testStereoRenderSumsVoicesPerChannel() { static void testStereoRenderNullBufferIsNoOp() { Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector buf(4, 0.f); eng.render(nullptr, buf.data(), 4); // null left -> no-op, no crash @@ -810,7 +810,7 @@ static void testStereoStartFrameLoopShareOneReadHead() { s.loop.end = 30; // loop [20,30): frames 20..29 CHECK(s.channelCount() == 2); Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); // unity ratio, full velocity, flat gain std::vector left(200, 0.f), right(200, 0.f); @@ -908,7 +908,7 @@ static SampleData triggerSample(std::size_t frames, double lengthFraction, static void testTriggerLengthFractionFrames() { // 200-frame sample, start 0, 50% length -> plays 100 frames then the voice frees. Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); // unity ratio std::vector out; eng.render(out, 200); @@ -922,7 +922,7 @@ static void testTriggerLengthFractionFrames() { static void testTriggerLengthWithStart() { // 200 frames, start 40, 50% -> span 160, play 80 frames (frames 40..119), then free. Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0, /*start=*/40)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 200); @@ -936,7 +936,7 @@ static void testTriggerFadeShape() { // 100 frames, 100% length, fadeIn 20, fadeOut 20. Head ramps 0->1, tail ramps 1->0, unity // between. Equal-power: sin/cos ramps, monotonic, endpoints ~0 and ~1. Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 1.0, 20, 20)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 120); @@ -956,7 +956,7 @@ static void testTriggerEdgeCases() { // %=0: zero play length -> voice frees at once, no sound. { Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 0.0, 5, 5)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 50); @@ -967,7 +967,7 @@ static void testTriggerEdgeCases() { { // 40 frames, 100% -> playLen 40; fadeIn 30 + fadeOut 30 = 60 > 40 -> clamped. Keymap km = Keymap::singleSampleChromatic(triggerSample(40, 1.0, 30, 30)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 50); @@ -977,7 +977,7 @@ static void testTriggerEdgeCases() { // %=100 plays the full post-start span. { Keymap km = Keymap::singleSampleChromatic(triggerSample(60, 1.0, 0, 0)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 80); @@ -989,7 +989,7 @@ static void testTriggerEdgeCases() { // --- Trigger ignores note-off (S15): the one-shot plays through regardless. --- static void testTriggerIgnoresNoteOff() { Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 10); @@ -1039,7 +1039,7 @@ static void testPreserveDurationInvariance() { auto lengthAt = [&](int note) -> std::size_t { Keymap km = Keymap::singleSampleChromatic(preserveTriggerSample(frames, 1.0)); - VoiceEngine eng(1, km, flatAdsr(), /*preserveCap=*/0, /*window=*/static_cast(window)); + VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/static_cast(window)); eng.noteOn(note, 127); return soundingLength(eng, 4000); }; @@ -1066,7 +1066,7 @@ static void testVarispeedStillCouplesDuration() { s.play.pitchEngine = PitchEngine::Varispeed; s.play.trigger.lengthFraction = 1.0; Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(note, 127); return soundingLength(eng, 4000); }; @@ -1091,7 +1091,7 @@ static void testPitchEnvOffBitIdentical() { s.play.pitchEnv.decayFrames = 500; } 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) std::vector out; eng.render(out, n); @@ -1119,7 +1119,7 @@ static void testPitchEnvOnBendsVarispeed() { s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames s.play.pitchEnv.peakSemitones = 12.0; // +1 octave at t=0 Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); // at root -> base ratio 1.0; the env supplies the bend std::vector out; eng.render(out, 4000); @@ -1154,7 +1154,7 @@ static void testPreserveGateStereoLoopComposes() { s.play.pitchEngine = PitchEngine::Preserve; CHECK(s.channelCount() == 2); 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) std::vector left(2000, 0.f), right(2000, 0.f); eng.render(left.data(), right.data(), 2000); @@ -1176,23 +1176,22 @@ static void testPreserveVoiceCap() { s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough) Keymap km = Keymap::singleSampleChromatic(std::move(s)); // 8 voices total, Preserve cap of 2. - VoiceEngine eng(8, km, 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(62, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap) CHECK(eng.noteOn(64, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap CHECK(eng.activeVoiceCount() == 2); } -// --- S12 review fix: per-zone A/D/S/R actually reaches the voice envelope. --- +// --- Per-zone A/D/S/R actually reaches the voice envelope (S12). --- // -// Before the fix, Voice::start used the instrument-wide gateAdsr for A/D/S/R and only -// folded the per-zone holdFrames. These two tests assert the corrected path. +// 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 — NOT the VoiceEngine's gateAdsr. -// Strategy: give the VoiceEngine a FLAT gateAdsr (instant attack) but 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. If it instead used -// gateAdsr (flat = instant), frame 0 would already be 1.0. This is the load-bearing proof. +// 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. @@ -1203,11 +1202,11 @@ static void testPerZoneAdsrReachesVoiceEnvelope() { 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, flatAdsr()); // instrument-wide gateAdsr = flat (instant attack) + VoiceEngine eng(1, km); eng.noteOn(60, 127); // unity pitch, full velocity -> gain 1.0 std::vector out; eng.render(out, 20); - // Frame 0: attack start, envelope near 0. If gateAdsr (flat) were used, this would be 1.0. + // 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); @@ -1226,7 +1225,7 @@ static void testZeroAdsrIsInstantSustain() { s.play.adsr = AdsrParams{}; s.play.pitchEngine = PitchEngine::Varispeed; Keymap km = Keymap::singleSampleChromatic(std::move(s)); - VoiceEngine eng(1, km, flatAdsr()); + VoiceEngine eng(1, km); eng.noteOn(60, 127); std::vector out; eng.render(out, 5); From 082c9b82c2b17bb69860c09109603d7636dd21e7 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 03:07:52 -0400 Subject: [PATCH 5/6] s12: thread project rate through v3 legacy lift; remove 44100 literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kLegacyV3NominalRate removed. readZonesPayload, deserializePerformance, deserializeComponentState now take a projectRate for v3 frames→seconds. Rate fields default 0 (invalid). Four 44100 fallbacks replaced with assert+safe-return. 96k v3-lift test added. --- src/vst/reasampler_processor.cpp | 6 +- src/vst/reasampler_processor.h | 5 +- src/vst/sample_map.cpp | 59 +++++++++----- src/vst/sample_map.h | 30 ++++--- src/vst/sampler_core.h | 6 +- tests/test_sample_map.cpp | 130 ++++++++++++++++++------------- 6 files changed, 144 insertions(+), 92 deletions(-) diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index c9baef0..c14f7ce 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -176,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 // the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10 // 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); setPerformanceMap(cs.map); // S8: restore the last-consumed assignment generation so a re-open does not re-apply a diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index b9c4299..fa7ff2e 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -251,8 +251,9 @@ private: std::int64_t lastSeenBankGeneration_ = -1; // Latched from setupProcessing so setActive/reload can size against it. Read - // off-thread only. - double sampleRate_ = 44100.0; + // off-thread only. 0.0 is explicitly invalid — setupProcessing sets the real host rate + // before any audio, and reloadFromBank guards on it before use. + double sampleRate_ = 0.0; Steinberg::int32 maxBlockSize_ = 4096; // --- S6 embedded TCP/MCP UI --------------------------------------------- diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index c80fee5..760332e 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -4,6 +4,7 @@ #include "sample_map.h" #include // std::min +#include // assert #include // std::memcpy #include // std::move @@ -118,8 +119,10 @@ std::vector extractChannel(const std::vector& interlea DecodedZonePcm decodeChannels(const std::vector& interleaved, int sourceChannels, ChannelMode mode, int sampleRate) { + assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)"); 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) { // MONO mode: the existing downmix policy (average all source channels), one channel out. out.monoFrames = downmixToMono(interleaved, sourceChannels); @@ -137,7 +140,8 @@ 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. - const double sr = sampleRate > 0 ? static_cast(sampleRate) : 44100.0; + assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)"); + const double sr = sampleRate > 0 ? static_cast(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first const auto secToFrames = [sr](double sec) { double f = sec * sr; if (f < 0.0) f = 0.0; @@ -162,6 +166,7 @@ ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) { Keymap buildTier0Keymap(std::vector frames, int sampleRate, int rootNote, const SampleLoop& loop, std::vector framesR, const ZonePlaySeconds& play) { + assert(sampleRate > 0 && "buildTier0Keymap: sampleRate must be > 0 (programming error)"); SampleData data; data.frames = std::move(frames); // A second channel only counts when it length-matches channel 0 (else the sample stays @@ -169,7 +174,8 @@ Keymap buildTier0Keymap(std::vector frames, int sampleRate, if (!framesR.empty() && framesR.size() == data.frames.size()) { 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.loop = loop; // Resolve the stored wall-clock SECONDS to the engine's frame domain at the WAV's actual rate. @@ -238,7 +244,10 @@ Keymap buildZonedKeymap(const std::vector& zones, decoded[i].framesR.size() == data.frames.size()) { 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.loop = zones[i].loop; data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0) @@ -400,7 +409,10 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) // loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail — // clean back-compat lift, the overrides simply default absent). A truncated mid-zone read // keeps the zones that parsed cleanly and drops the rest. -void readZonesPayload(ByteReader& r, PerformanceMap& map) { +// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock frame +// counts (holdFrames, pitchEnv A/D) to the seconds domain at the read boundary: seconds = frames / +// projectRate. Must be > 0 (callers guard). v5 and later blobs carry seconds directly; no rate needed. +void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) { bool extended = false; // v2+: the S11 loop/start tail is present std::uint32_t pv = 0; // payload version (0 = v1, no marker) if (r.peekU32() == kZonesFormatMarker) { @@ -435,18 +447,20 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) { } if (legacyV3Play) { // LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D) - // were written as 44.1k-nominal frames -> divide by kLegacyV3NominalRate 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 (0.003 / 0 / 1.0 / 0.060). + // were written as frames -> divide by the project sample rate (threaded in as `projectRate`) + // to reach the seconds domain. Trigger %-length + fades are source-timeline, read as-is. + // A/D/S/R are ABSENT in v3 -> leave the seconds defaults on z.play.adsr. + assert(projectRate > 0.0 && "readZonesPayload: projectRate must be > 0 for v3 lift"); + const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // 1.0 avoids div-by-zero; assert fires first z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate; - z.play.adsr.holdSeconds = static_cast(r.i64()) / kLegacyV3NominalRate; + z.play.adsr.holdSeconds = static_cast(r.i64()) / liftRate; z.play.trigger.lengthFraction = bitsToDouble(r.u64()); z.play.trigger.fadeInFrames = r.i64(); z.play.trigger.fadeOutFrames = r.i64(); z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed; z.play.pitchEnv.enabled = (r.u8() != 0); - z.play.pitchEnv.attackSeconds = static_cast(r.i64()) / kLegacyV3NominalRate; - z.play.pitchEnv.decaySeconds = static_cast(r.i64()) / kLegacyV3NominalRate; + z.play.pitchEnv.attackSeconds = static_cast(r.i64()) / liftRate; + z.play.pitchEnv.decaySeconds = static_cast(r.i64()) / liftRate; z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64()); } else if (secondsPlay) { // Current v5 play tail: wall-clock times in SECONDS (doubles); trigger fades in source @@ -482,7 +496,11 @@ std::vector serializePerformance(const PerformanceMap& map) { return out; } -PerformanceMap deserializePerformance(const std::vector& bytes) { +PerformanceMap deserializePerformance(const std::vector& bytes, + double projectRate) { + // projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. + // For v5 and later blobs it is unused. The assert inside readZonesPayload fires if a v3 + // blob is encountered with an invalid rate — the calller guarantees a real rate before use. PerformanceMap map; ByteReader r(bytes); const std::uint32_t version = r.u32(); @@ -503,7 +521,7 @@ PerformanceMap deserializePerformance(const std::vector& bytes) { } if (version != kPerformanceStateVersion) return map; // unknown -> empty - readZonesPayload(r, map); + readZonesPayload(r, map, projectRate); return map; } @@ -526,7 +544,10 @@ std::vector serializeComponentState(const ComponentState& state) { return out; } -ComponentState deserializeComponentState(const std::vector& bytes) { +ComponentState deserializeComponentState(const std::vector& bytes, + double projectRate) { + // projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. + // For v5 and later blobs it is unused. See readZonesPayload for the guard. ComponentState out; ByteReader r(bytes); const std::uint32_t version = r.u32(); @@ -549,8 +570,8 @@ ComponentState deserializeComponentState(const std::vector& bytes) return out; } if (version == kPerformanceStateVersion) { - readZonesPayload(r, out.map); // v2 body starts right after the version tag - return out; // channelMode stays Mono (pre-S7) + readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag + return out; // channelMode stays Mono (pre-S7) } // BACK-COMPAT: a v3 blob (pre-S7 {selection, zones}, no channel mode) restores as MONO — // the id length + id + zones body starts right after the version tag (no mode byte). @@ -558,7 +579,7 @@ ComponentState deserializeComponentState(const std::vector& bytes) const std::uint32_t idLen = r.u32(); out.selectionId = r.str(idLen); if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty - readZonesPayload(r, out.map); + readZonesPayload(r, out.map, projectRate); return out; // channelMode stays Mono, marker stays 0 (pre-S7/S8/S9) } // BACK-COMPAT: a v4 blob (pre-S8/S9 reader {mode, selection, zones}, no consumed marker): @@ -571,7 +592,7 @@ ComponentState deserializeComponentState(const std::vector& bytes) const std::uint32_t idLen = r.u32(); out.selectionId = r.str(idLen); if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty - readZonesPayload(r, out.map); + readZonesPayload(r, out.map, projectRate); return out; // marker stays 0 (pre-S8/S9 reader) } if (version != kComponentStateVersion) return out; // unknown -> empty @@ -587,7 +608,7 @@ ComponentState deserializeComponentState(const std::vector& bytes) const std::uint32_t idLen = r.u32(); out.selectionId = r.str(idLen); if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty - readZonesPayload(r, out.map); + readZonesPayload(r, out.map, projectRate); return out; } diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index 810999b..2bd5d79 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -260,7 +260,8 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson, // map). Empty zones in -> empty Keymap (silence). struct DecodedZonePcm { std::vector 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 framesR; // channel 1 (R); EMPTY for a mono decode }; Keymap buildZonedKeymap(const std::vector& zones, @@ -323,10 +324,10 @@ DecodedZonePcm decodeChannels(const std::vector& interleaved, // no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved // instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest. // LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS -// written by the S15/S16 editor as 44.1k-nominal frames (that build's slider domain was fixed at -// 44100), so they convert to the seconds domain by dividing by that authoring-time nominal rate -// (kLegacyV3NominalRate). 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), no rate. +// 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): @@ -364,18 +365,20 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2; inline constexpr std::uint32_t kZonesPayloadVersion = 5; // S12: full per-zone play params, SECONDS inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; -// The authoring-time nominal rate the LEGACY v3 zone payload's wall-clock frame counts (hold, -// pitchEnv A/D) were always written at (the S15/S16 editor's slider domain was fixed at 44100 Hz). -// Used ONLY at the v3 read boundary to convert those legacy frames to the seconds domain — it is a -// property of the frozen v3 wire format, not a live program rate. No other site may reference it. -inline constexpr double kLegacyV3NominalRate = 44100.0; +// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are +// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a +// parameter — frames ÷ projectRate = seconds. The project rate is the same rate keymap build +// already receives, so the seconds domain is consistent across both paths. No constant is baked in. // The performance map serialized to bytes for IBStream (getState). std::vector serializePerformance(const PerformanceMap& map); // The performance map parsed back from IBStream bytes (setState). A v2 blob parses // directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map. -PerformanceMap deserializePerformance(const std::vector& 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& bytes, + double projectRate); // --- Combined component state (VST3 setState/getState, v3 — S10) ------------- // @@ -432,7 +435,10 @@ std::vector serializeComponentState(const ComponentState& state); // The full instance state parsed back from IBStream bytes (setState). Tolerant of // truncation/wrong-version (bounded reads, never throws); older blobs lift per the table // above so already-saved instances restore cleanly. -ComponentState deserializeComponentState(const std::vector& 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& bytes, + double projectRate); // --- Instance state (VST3 setState/getState) -------------------------------- // diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index 98f826b..3027f78 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -145,8 +145,10 @@ struct SampleLoop { struct SampleData { std::vector frames; // channel 0 PCM (mono, or L of a stereo sample) std::vector framesR; // channel 1 PCM (R); EMPTY for a mono sample - int sampleRate = 44100; // frames per second (for reference; ratio is - // note-relative, so rate cancels for repitch) + int sampleRate = 0; // frames per second (for reference; ratio is + // note-relative, so rate cancels for repitch). + // 0 is explicitly invalid — every consumer must + // receive a real rate before use. int rootNote = 60; // MIDI note recorded at (plays at unity here) SampleLoop loop; // sustain loop, if any // Initial read position (frame offset) a voice starts playback at — frame 0 by diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 73609a2..f5e28fb 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -262,12 +262,6 @@ static void testBuildKeymapSingleFullZone() { 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) -------------------------------------- @@ -279,6 +273,7 @@ static void testSelectionStateRoundTrip() { CHECK(deserializeSelection(bytes) == id); } + static void testSelectionStateEmptyId() { const std::vector bytes = serializeSelection(""); 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("snare", 48, 59, /*override=*/50)); // with override const std::vector 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 && back.zones[0].sampleId == "kick"); 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 bytes = serializePerformance(PerformanceMap{}); // Envelope version (4) + zones-payload marker (4) + payload version (4) + zero count (4). CHECK(bytes.size() == 16); - CHECK(deserializePerformance(bytes).zones.empty()); + CHECK(deserializePerformance(bytes, 44100.0).zones.empty()); } static void testPerformanceStateLoopStartRoundTrip() { @@ -630,7 +625,7 @@ static void testPerformanceStateLoopStartRoundTrip() { m.zones.push_back(z); // A second zone with NO overrides proves the optional tail is per-record. m.zones.push_back(zone("kick", 0, 23)); - const PerformanceMap back = deserializePerformance(serializePerformance(m)); + const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); CHECK(back.zones.size() == 2); CHECK(back.zones.size() == 2 && back.zones[0].rootOverride.has_value() && *back.zones[0].rootOverride == 64); @@ -662,7 +657,7 @@ static void testPerformanceStateV1PayloadBackCompat() { u32(10); // lowNote u32(40); // highNote 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].lowNote == 10 && back.zones[0].highNote == 40); CHECK(back.zones.size() == 1 && !back.zones[0].loopOverride.has_value()); @@ -679,7 +674,7 @@ static void testComponentStateLoopStartRoundTrip() { z.loopOverride = lp; z.startPoint = 128; 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.map.zones.size() == 1 && back.map.zones[0].loopOverride.has_value() && back.map.zones[0].loopOverride->start == 500 && @@ -691,25 +686,25 @@ static void testComponentStateLoopStartRoundTrip() { static void testPerformanceStateV1BackCompat() { // A v1 blob (the S4 single-selection format) lifts to a single full-keyboard zone. const std::vector v1 = serializeSelection("legacy-sample-id"); - const PerformanceMap back = deserializePerformance(v1); + const PerformanceMap back = deserializePerformance(v1, 44100.0); CHECK(back.zones.size() == 1); CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy-sample-id"); CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 0 && back.zones[0].highNote == 127); CHECK(back.zones.size() == 1 && !back.zones[0].rootOverride.has_value()); // A v1 blob with an EMPTY id lifts to an empty map (no zone for "no selection"). - CHECK(deserializePerformance(serializeSelection("")).zones.empty()); + CHECK(deserializePerformance(serializeSelection(""), 44100.0).zones.empty()); } static void testPerformanceStateGarbage() { // Unknown version / truncated / empty -> empty map (never throws). - CHECK(deserializePerformance({}).zones.empty()); - CHECK(deserializePerformance({0xAA, 0xBB, 0xCC, 0xDD}).zones.empty()); // unknown version + CHECK(deserializePerformance({}, 44100.0).zones.empty()); + CHECK(deserializePerformance({0xAA, 0xBB, 0xCC, 0xDD}, 44100.0).zones.empty()); // unknown version // Truncated mid-zone: valid v2 header claiming 1 zone but no zone bytes -> empty. std::vector t; t.push_back(2); t.push_back(0); t.push_back(0); t.push_back(0); // version 2 t.push_back(1); t.push_back(0); t.push_back(0); t.push_back(0); // count 1 // (no zone payload) - CHECK(deserializePerformance(t).zones.empty()); + CHECK(deserializePerformance(t, 44100.0).zones.empty()); } 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). PerformanceMap m; 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() && *back.zones[0].rootOverride == 0); } @@ -730,7 +725,7 @@ static void testComponentStateRoundTrip() { s.selectionId = "picked-capture"; s.map.zones.push_back(zone("z0", 0, 59, /*override=*/std::nullopt)); s.map.zones.push_back(zone("z1", 60, 127, /*override=*/48)); - const ComponentState back = deserializeComponentState(serializeComponentState(s)); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); CHECK(back.selectionId == "picked-capture"); CHECK(back.map.zones.size() == 2); CHECK(back.map.zones.size() == 2 && back.map.zones[0].sampleId == "z0" && @@ -744,7 +739,7 @@ static void testComponentStateSelectionOnlyNoZones() { // (NOT synthesize a zone) — the default face is one capture, zones are opt-in. ComponentState s; s.selectionId = "just-a-pick"; - const ComponentState back = deserializeComponentState(serializeComponentState(s)); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); CHECK(back.selectionId == "just-a-pick"); CHECK(back.map.zones.empty()); } @@ -752,7 +747,7 @@ static void testComponentStateSelectionOnlyNoZones() { static void testComponentStateEmptyIsEmpty() { // No pick, no zones -> restores EMPTY (the S10 silent empty state), never a first sample. const ComponentState s; // selectionId "", empty map - const ComponentState back = deserializeComponentState(serializeComponentState(s)); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); CHECK(back.selectionId.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 // survives as BOTH the selection and a one-zone map. const std::vector v1 = serializeSelection("legacy-id"); - const ComponentState back = deserializeComponentState(v1); + const ComponentState back = deserializeComponentState(v1, 44100.0); CHECK(back.selectionId == "legacy-id"); CHECK(back.map.zones.size() == 1); CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "legacy-id" && back.map.zones[0].lowNote == 0 && back.map.zones[0].highNote == 127); // A v1 blob with an EMPTY id -> empty state (no selection, no zone). - const ComponentState empty = deserializeComponentState(serializeSelection("")); + const ComponentState empty = deserializeComponentState(serializeSelection(""), 44100.0); CHECK(empty.selectionId.empty() && empty.map.zones.empty()); } @@ -777,7 +772,7 @@ static void testComponentStateV2BackCompat() { PerformanceMap m; m.zones.push_back(zone("s", 12, 24, /*override=*/std::nullopt)); const std::vector v2 = serializePerformance(m); - const ComponentState back = deserializeComponentState(v2); + const ComponentState back = deserializeComponentState(v2, 44100.0); CHECK(back.selectionId.empty()); CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "s" && back.map.zones[0].lowNote == 12 && back.map.zones[0].highNote == 24); @@ -785,17 +780,17 @@ static void testComponentStateV2BackCompat() { static void testComponentStateGarbage() { // Empty / unknown version -> empty (never throws across the host). - CHECK(deserializeComponentState({}).selectionId.empty()); - CHECK(deserializeComponentState({}).map.zones.empty()); + CHECK(deserializeComponentState({}, 44100.0).selectionId.empty()); + CHECK(deserializeComponentState({}, 44100.0).map.zones.empty()); const std::vector unknown{0xAA, 0xBB, 0xCC, 0xDD}; - CHECK(deserializeComponentState(unknown).map.zones.empty()); - CHECK(deserializeComponentState(unknown).selectionId.empty()); + CHECK(deserializeComponentState(unknown, 44100.0).map.zones.empty()); + CHECK(deserializeComponentState(unknown, 44100.0).selectionId.empty()); // A v3 header claiming a longer id than the blob holds -> empty (bounded read). std::vector t; t.push_back(3); t.push_back(0); t.push_back(0); t.push_back(0); // version 3 t.push_back(200); t.push_back(0); t.push_back(0); t.push_back(0); // id length 200 (absent) - CHECK(deserializeComponentState(t).selectionId.empty()); - CHECK(deserializeComponentState(t).map.zones.empty()); + CHECK(deserializeComponentState(t, 44100.0).selectionId.empty()); + CHECK(deserializeComponentState(t, 44100.0).map.zones.empty()); } // --- S7: extractChannel / decodeChannels (cross-mode channel policy) ---------- @@ -887,7 +882,7 @@ static void testComponentStateV4RoundTripStereo() { s.selectionId = "pick"; s.channelMode = ChannelMode::Stereo; s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt)); - const ComponentState back = deserializeComponentState(serializeComponentState(s)); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); CHECK(back.selectionId == "pick"); CHECK(back.channelMode == ChannelMode::Stereo); // mode round-trips CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0"); @@ -897,14 +892,14 @@ static void testComponentStateV4RoundTripMono() { ComponentState s; s.selectionId = "pick"; s.channelMode = ChannelMode::Mono; - const ComponentState back = deserializeComponentState(serializeComponentState(s)); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); CHECK(back.selectionId == "pick"); CHECK(back.channelMode == ChannelMode::Mono); } static void testComponentStateV4DefaultIsMono() { // A default-constructed state serializes with mono and restores mono (preserves behavior). - const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{})); + const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0); CHECK(back.channelMode == ChannelMode::Mono); CHECK(back.selectionId.empty() && back.map.zones.empty()); } @@ -918,7 +913,7 @@ static void testComponentStateV3LiftsToMono() { v3.push_back(static_cast(id.size())); v3.push_back(0); v3.push_back(0); v3.push_back(0); v3.insert(v3.end(), id.begin(), id.end()); v3.push_back(0); v3.push_back(0); v3.push_back(0); v3.push_back(0); // zone count 0 - const ComponentState back = deserializeComponentState(v3); + const ComponentState back = deserializeComponentState(v3, 44100.0); CHECK(back.selectionId == "legacy"); CHECK(back.channelMode == ChannelMode::Mono); // pre-S7 default CHECK(back.map.zones.empty()); @@ -926,17 +921,17 @@ static void testComponentStateV3LiftsToMono() { static void testComponentStateV1V2LiftToMono() { // The older lifts (v1 single-selection, v2 zones-only) also default to mono under v4 read. - const ComponentState v1 = deserializeComponentState(serializeSelection("old")); + const ComponentState v1 = deserializeComponentState(serializeSelection("old"), 44100.0); CHECK(v1.channelMode == ChannelMode::Mono && v1.selectionId == "old"); PerformanceMap m; m.zones.push_back(zone("s", 12, 24)); - const ComponentState v2 = deserializeComponentState(serializePerformance(m)); + const ComponentState v2 = deserializeComponentState(serializePerformance(m), 44100.0); CHECK(v2.channelMode == ChannelMode::Mono && v2.map.zones.size() == 1); } static void testComponentStateV4TruncatedModeByte() { // A v4 blob truncated right after the version tag (no mode byte) -> empty, mono default holds. std::vector t{4, 0, 0, 0}; // version 4, nothing after - const ComponentState back = deserializeComponentState(t); + const ComponentState back = deserializeComponentState(t, 44100.0); CHECK(back.channelMode == ChannelMode::Mono); 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(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.selectionId == "pick"); CHECK(back.map.zones.size() == 2); @@ -989,7 +984,7 @@ static void testComponentStateV5MarkerRoundTrip() { s.channelMode = ChannelMode::Stereo; s.lastConsumedAssignGeneration = 1700000123456LL; // > INT32_MAX s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt)); - const ComponentState back = deserializeComponentState(serializeComponentState(s)); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // marker survives CHECK(back.channelMode == ChannelMode::Stereo); CHECK(back.selectionId == "pick"); @@ -999,7 +994,7 @@ static void testComponentStateV5MarkerRoundTrip() { static void testComponentStateDefaultMarkerIsZero() { // A default-constructed state has marker 0 and round-trips 0 — a fresh instance's first // assign (generation >= 1) must not be swallowed by a non-zero default. - const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{})); + const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0); CHECK(back.lastConsumedAssignGeneration == 0); } @@ -1015,7 +1010,7 @@ static void testComponentStateV4LiftsMarkerToZero() { v4.push_back(static_cast(id.size())); v4.push_back(0); v4.push_back(0); v4.push_back(0); v4.insert(v4.end(), id.begin(), id.end()); v4.push_back(0); v4.push_back(0); v4.push_back(0); v4.push_back(0); // zone count 0 - const ComponentState back = deserializeComponentState(v4); + const ComponentState back = deserializeComponentState(v4, 44100.0); CHECK(back.lastConsumedAssignGeneration == 0); // no marker in v4 -> default 0 CHECK(back.channelMode == ChannelMode::Stereo); // v4 mode byte still honored CHECK(back.selectionId == "saved"); @@ -1026,7 +1021,7 @@ static void testComponentStateV5TruncatedMarker() { // A v5 blob truncated inside the 8-byte marker (mode byte present, marker cut short) -> empty, // mono + marker 0 default holds (bounded read, never throws across the host). std::vector t{5, 0, 0, 0, 1, 0xAA, 0xBB}; // version 5, mode byte, 2 marker bytes - const ComponentState back = deserializeComponentState(t); + const ComponentState back = deserializeComponentState(t, 44100.0); CHECK(back.lastConsumedAssignGeneration == 0); CHECK(back.selectionId.empty() && back.map.zones.empty()); } @@ -1059,7 +1054,7 @@ static void testV5EnvelopeWithMarkerAndPlayParamsRoundTrip() { z.play.pitchEnv.peakSemitones = 12.5; 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); // envelope: mode CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // envelope: marker CHECK(back.selectionId == "pick"); @@ -1133,7 +1128,7 @@ static void testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay() { const std::vector payload = handBuildV3PayloadOneZone("zv3"); v4.insert(v4.end(), payload.begin(), payload.end()); - const ComponentState back = deserializeComponentState(v4); + 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"); @@ -1143,8 +1138,8 @@ static void testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay() { 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 (44.1k-nominal) convert to seconds at the v3 authoring rate. - CHECK(approx(p.adsr.holdSeconds, 2048.0 / kLegacyV3NominalRate)); + // 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); @@ -1169,7 +1164,7 @@ static void testPlayParamsRoundTrip() { z.play.pitchEnv.decaySeconds = 0.011; z.play.pitchEnv.peakSemitones = -7.5; m.zones.push_back(z); - const PerformanceMap back = deserializePerformance(serializePerformance(m)); + const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); CHECK(back.zones.size() == 1); if (back.zones.size() != 1) return; const ZonePlaySeconds& p = back.zones[0].play; @@ -1196,7 +1191,7 @@ static void testPlayParamsComposeWithLoopStart() { z.play.adsr.holdSeconds = 0.0225; z.play.pitchEngine = PitchEngine::Preserve; m.zones.push_back(z); - const PerformanceMap back = deserializePerformance(serializePerformance(m)); + const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); CHECK(back.zones.size() == 1); if (back.zones.size() != 1) return; CHECK(back.zones[0].loopOverride.has_value() && @@ -1227,7 +1222,7 @@ static void testPlayParamsV2BackCompatLiftsToDefaults() { b.push_back(0); // hasRootOverride = 0 b.push_back(0); // hasLoopOverride = 0 b.push_back(0); // hasStartPoint = 0 (record ends here in v2) - const PerformanceMap back = deserializePerformance(b); + const PerformanceMap back = deserializePerformance(b, 44100.0); CHECK(back.zones.size() == 1); if (back.zones.size() != 1) return; CHECK(back.zones[0].sampleId == "old"); @@ -1249,7 +1244,7 @@ static void testPlayParamsThroughComponentEnvelope() { z.play.trigger.lengthFraction = 0.9; z.play.pitchEngine = PitchEngine::Varispeed; 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.map.zones.size() == 1); if (back.map.zones.size() != 1) return; @@ -1276,7 +1271,7 @@ static void testFullAdsrSecondsRoundTrip() { z.play.adsr.releaseSeconds = 0.2; z.play.pitchEngine = PitchEngine::Preserve; m.zones.push_back(z); - const PerformanceMap back = deserializePerformance(serializePerformance(m)); + 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; @@ -1300,18 +1295,41 @@ static void testV3BlobLiftsAdsrToSecondsDefaults() { u32(kPerformanceStateVersion); // envelope version 2 header const std::vector payload = handBuildV3PayloadOneZone("old"); blob.insert(blob.end(), payload.begin(), payload.end()); - const PerformanceMap back = deserializePerformance(blob); + 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 44.1k-nominal frames; A/D/S/R lift to the seconds defaults. - CHECK(approx(a.holdSeconds, 2048.0 / kLegacyV3NominalRate)); // from the hand-built v3 record + // hold converts from the v3 record's frames at the passed project rate (44100.0 here). + CHECK(approx(a.holdSeconds, 2048.0 / 44100.0)); // from the hand-built v3 record CHECK(a.attackSeconds == AdsrSeconds{}.attackSeconds); // 0.003 (tier-0 default seconds) CHECK(a.decaySeconds == AdsrSeconds{}.decaySeconds); // 0.0 CHECK(a.sustainLevel == AdsrSeconds{}.sustainLevel); // 1.0 CHECK(a.releaseSeconds == AdsrSeconds{}.releaseSeconds); // 0.060 } +// A legacy PAYLOAD v3 blob decoded at 96k: the hold frame count (2048) converts using the +// PASSED project rate, not a baked 44100 constant. At 96000 the seconds value is 2048/96000. +static void testV3BlobLiftsAdsrAt96k() { + std::vector blob; + auto u32 = [&](std::uint32_t v) { + blob.push_back(v & 0xFF); blob.push_back((v >> 8) & 0xFF); + blob.push_back((v >> 16) & 0xFF); blob.push_back((v >> 24) & 0xFF); + }; + u32(kPerformanceStateVersion); // envelope version 2 header + const std::vector payload = handBuildV3PayloadOneZone("old96k"); + blob.insert(blob.end(), payload.begin(), payload.end()); + const PerformanceMap back = deserializePerformance(blob, 96000.0); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + const AdsrSeconds& a = back.zones[0].play.adsr; + // 2048 frames at 96000 Hz -> 2048/96000 seconds (not 2048/44100). + CHECK(approx(a.holdSeconds, 2048.0 / 96000.0)); + CHECK(a.attackSeconds == AdsrSeconds{}.attackSeconds); + CHECK(a.decaySeconds == AdsrSeconds{}.decaySeconds); + CHECK(a.sustainLevel == AdsrSeconds{}.sustainLevel); + CHECK(a.releaseSeconds == AdsrSeconds{}.releaseSeconds); +} + // The lift -> commit -> reload sequence must stay rate-correct at 44.1k / 48k / 96k. A DEFAULT zone // resolves to the tier-0 wall-clock durations at each rate (round(0.003*rate), round(0.060*rate)); // an AUTHORED zone resolves to round(seconds*rate). This is the R2 blocker, pinned across rates. @@ -1324,7 +1342,7 @@ static void testKeymapBuildResolvesSecondsToFramesAtEachRate() { { PerformanceMap m; m.zones.push_back(zone("def", 0, 127)); // product-default play (tier-0 AHDSR seconds) - const PerformanceMap back = deserializePerformance(serializePerformance(m)); + 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; @@ -1348,7 +1366,7 @@ static void testKeymapBuildResolvesSecondsToFramesAtEachRate() { z.play.adsr.sustainLevel = 0.7; z.play.adsr.releaseSeconds = 0.2; m.zones.push_back(z); - const PerformanceMap back = deserializePerformance(serializePerformance(m)); + 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; @@ -1400,7 +1418,6 @@ int main() { testDownmixThreeChannelAverages(); testDownmixDegenerate(); testBuildKeymapSingleFullZone(); - testBuildKeymapRateDefault(); testSelectionStateRoundTrip(); testSelectionStateEmptyId(); testSelectionStateWrongVersion(); @@ -1433,6 +1450,7 @@ int main() { testPlayParamsThroughComponentEnvelope(); testFullAdsrSecondsRoundTrip(); testV3BlobLiftsAdsrToSecondsDefaults(); + testV3BlobLiftsAdsrAt96k(); testKeymapBuildResolvesSecondsToFramesAtEachRate(); testBuildTier0KeymapResolvesSecondsAt48k(); testComponentStateRoundTrip(); From b2a45335634c367ab502f0600f1632893145684b Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 03:18:10 -0400 Subject: [PATCH 6/6] fix(comments): correct stale v3 "CURRENT" payload prose to v5 in sample_map --- src/vst/sample_map.cpp | 12 +++++++----- src/vst/sample_map.h | 15 ++++++++------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index 760332e..5f07b99 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -353,11 +353,13 @@ struct ByteReader { } }; -// Append the zones payload — the shared body of the v2 performance blob and the v3 component -// blob, so both write zones identically. Always emits PAYLOAD v2 (the S11 self-describing -// marker + version + EXTENDED records): the marker precedes the zone count so any reader can -// detect the record shape independently of the envelope version (see sample_map.h). The S11 -// loop/start overrides therefore round-trip through EITHER envelope with no envelope bump. +// Append the zones payload — the shared body of the performance blob and the component blob, +// so both write zones identically. Always emits the CURRENT PAYLOAD version (kZonesPayloadVersion +// == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail +// AND the full play-params tail with wall-clock times stored as SECONDS): the marker precedes +// the zone count so any reader can detect the record shape independently of the envelope version +// (see sample_map.h). The S11 loop/start overrides and the play params therefore round-trip +// through EITHER envelope with no envelope bump. void putZonesPayload(std::vector& out, const PerformanceMap& map) { putU32le(out, kZonesFormatMarker); putU32le(out, kZonesPayloadVersion); diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index 2bd5d79..fac8cf4 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -355,13 +355,14 @@ DecodedZonePcm decodeChannels(const std::vector& interleaved, inline constexpr std::uint32_t kPerformanceStateVersion = 2; -// The zones-payload format version and its detection marker (S11/S15/S16). serializePerformance -// and serializeComponentState both emit the CURRENT payload version (v3 — marker + version + -// records with the S11 loop/start tail AND the S15/S16 play-params tail) so the overrides -// round-trip through EITHER envelope. Readers accept a v1 payload (no marker) and a v2 payload -// (marker + version 2, no play tail) for back-compat, lifting the missing fields to defaults. -// The marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in -// practice, always tiny) can never collide with. +// The zones-payload format version and its detection marker (S11/S15/S16/S12). serializePerformance +// and serializeComponentState both emit the CURRENT payload version (v5 — marker + version + +// records with the S11 loop/start tail AND the full play-params tail with wall-clock times in +// SECONDS) so the overrides round-trip through EITHER envelope. Readers accept a v1 payload (no +// marker), a v2 payload (marker + version 2, no play tail), and a v3 payload (legacy S15/S16 +// play tail with wall-clock frame counts) for back-compat, lifting missing fields to defaults. +// v4 was never shipped and is not read. The marker is a high sentinel that a legitimate zone +// count (bounded by 128 MIDI zones in practice, always tiny) can never collide with. inline constexpr std::uint32_t kZonesPayloadVersion = 5; // S12: full per-zone play params, SECONDS inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;