From 43155cf3204965e9b6b66fbb296ba12f7da4cb56 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 23:08:04 -0400 Subject: [PATCH] =?UTF-8?q?Phase=20S=20FB1=20(r11):=20Sample-view=20recomp?= =?UTF-8?q?osition=20=E2=80=94=20knob=20deck=20+=20elastic=20full-width=20?= =?UTF-8?q?hero=20+=20curve=20popup=20w/=20right-click=20delete=20+=20post?= =?UTF-8?q?-mixer=20master=20gain=20(ComponentState=20v8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 48 +- src/vst/curve_popup.cpp | 41 ++ src/vst/curve_popup.h | 48 ++ src/vst/knob_deck.cpp | 155 +++++ src/vst/knob_deck.h | 133 ++++ src/vst/master_gain.cpp | 51 ++ src/vst/master_gain.h | 54 ++ src/vst/reasampler_editor.cpp | 1031 +++++++++++++++++++++--------- src/vst/reasampler_editor.h | 77 ++- src/vst/reasampler_processor.cpp | 34 + src/vst/reasampler_processor.h | 16 + src/vst/sample_map.cpp | 32 +- src/vst/sample_map.h | 43 +- tests/test_curve_popup.cpp | 95 +++ tests/test_knob_deck.cpp | 199 ++++++ tests/test_master_gain.cpp | 103 +++ tests/test_sample_map.cpp | 106 +++ 17 files changed, 1942 insertions(+), 324 deletions(-) create mode 100644 src/vst/curve_popup.cpp create mode 100644 src/vst/curve_popup.h create mode 100644 src/vst/knob_deck.cpp create mode 100644 src/vst/knob_deck.h create mode 100644 src/vst/master_gain.cpp create mode 100644 src/vst/master_gain.h create mode 100644 tests/test_curve_popup.cpp create mode 100644 tests/test_knob_deck.cpp create mode 100644 tests/test_master_gain.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 2394361..2bc7b01 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -712,7 +712,9 @@ target_link_libraries(embed_strip PUBLIC editor_geometry) # calls these; the process callback stays allocation-free. add_library(sample_map STATIC src/vst/sample_map.cpp) target_include_directories(sample_map PUBLIC src/vst src) -target_link_libraries(sample_map PUBLIC bank_book wav_trim sampler_core) +# master_gain: the v8 component-state master-gain field validates against the pure taper's +# linear cap at the (de)serialization boundary (one cap, shared with the knob + the processor). +target_link_libraries(sample_map PUBLIC bank_book wav_trim sampler_core master_gain) # capture_browser (Phase S10) — PURE card-grid + bank-filter-tab layout + hit-test for the # capture-first editor's default face. The mirror of mode_switch/editor_geometry: the fiddly @@ -801,6 +803,29 @@ add_library(envelope_edit STATIC src/vst/envelope_edit.cpp) target_include_directories(envelope_edit PUBLIC src/vst) target_link_libraries(envelope_edit PUBLIC envelope_overlay) +# knob_deck (Wave B FB1, r11) — PURE knob-deck layout + hit-test for the recomposed Sample face: +# fenced groups (caption row + compact caption toggles + fixed 48x58 knob cells + optional row +# toggle), deterministic whole-group wrap, point -> control-id hit-test. The mirror of +# action_bar / param_slider; links editor_geometry for the shared Rect. Engine-free — cells and +# toggles carry opaque shell-owned control ids. NEITHER SDK. +add_library(knob_deck STATIC src/vst/knob_deck.cpp) +target_include_directories(knob_deck PUBLIC src/vst) +target_link_libraries(knob_deck PUBLIC editor_geometry) + +# curve_popup (Wave B FB1, r11) — PURE centered-sheet geometry for the velocity-curve popup +# editor: size clamps (60%/55% of window, 360..520 x 260..380), title row + close button, the +# curve-box border rect, and the outside-sheet dismissal test. The mirror of overflow_menu; +# links editor_geometry for the shared Rect. NEITHER SDK. +add_library(curve_popup STATIC src/vst/curve_popup.cpp) +target_include_directories(curve_popup PUBLIC src/vst) +target_link_libraries(curve_popup PUBLIC editor_geometry) + +# master_gain (Wave B FB1) — PURE dB<->linear<->knob-taper math for the post-mixer master gain +# (-inf..+24 dB; norm 0 = TRUE zero linear). One formula shared by the editor's Gain knob, the +# v8 component-state wire cap, and the processor's applied gain. Standard library only. NEITHER SDK. +add_library(master_gain STATIC src/vst/master_gain.cpp) +target_include_directories(master_gain PUBLIC src/vst) + 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) @@ -875,6 +900,21 @@ add_executable(envelope_edit_tests tests/test_envelope_edit.cpp) target_link_libraries(envelope_edit_tests PRIVATE envelope_edit) add_test(NAME envelope_edit_tests COMMAND envelope_edit_tests) +# knob_deck (Wave B FB1): the pure r11 deck layout/wrap/hit-test. Links ONLY knob_deck — NEITHER SDK. +add_executable(knob_deck_tests tests/test_knob_deck.cpp) +target_link_libraries(knob_deck_tests PRIVATE knob_deck) +add_test(NAME knob_deck_tests COMMAND knob_deck_tests) + +# curve_popup (Wave B FB1): the pure r11 popup-sheet geometry at the size clamps. NEITHER SDK. +add_executable(curve_popup_tests tests/test_curve_popup.cpp) +target_link_libraries(curve_popup_tests PRIVATE curve_popup) +add_test(NAME curve_popup_tests COMMAND curve_popup_tests) + +# master_gain (Wave B FB1): the pure dB<->linear<->knob taper. NEITHER SDK. +add_executable(master_gain_tests tests/test_master_gain.cpp) +target_link_libraries(master_gain_tests PRIVATE master_gain) +add_test(NAME master_gain_tests COMMAND master_gain_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -1081,10 +1121,14 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # and the node hit-test + pixel-delta -> clamped-param inverse map the Sample-view envelope # overlay draws + drags against; envelope_edit links envelope_overlay transitively (shared # node vocabulary + timeToX/levelToY). Both engine-free, DAW-verified in the shell. + # knob_deck + curve_popup + master_gain (Wave B FB1, r11): the pure deck layout/hit-test, + # the curve-popup sheet geometry, and the master-gain taper the recomposed Sample face + # draws + routes against (master_gain also rides in via sample_map for the v8 wire cap). target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal sample_map capture_paths embed_strip app_version capture_browser keyboard_strip waveform_view bank_sync browser_scroll note_entry param_slider - theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit) + theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit + knob_deck curve_popup master_gain) # 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/src/vst/curve_popup.cpp b/src/vst/curve_popup.cpp new file mode 100644 index 0000000..07ffe53 --- /dev/null +++ b/src/vst/curve_popup.cpp @@ -0,0 +1,41 @@ +// curve_popup.cpp — see curve_popup.h. Pure arithmetic; no LICE/VST3/REAPER includes. + +#include "curve_popup.h" + +#include + +namespace reasampler::vst { + +namespace { +int clampDim(int want, int lo, int hi, int windowDim) { + const int clamped = (std::max)(lo, (std::min)(hi, want)); + return (std::min)(clamped, (std::max)(0, windowDim)); +} +} // namespace + +CurvePopupLayout computeCurvePopup(int w, int h) { + CurvePopupLayout out; + const int sheetW = clampDim((w * 60) / 100, kCurvePopupMinW, kCurvePopupMaxW, w); + const int sheetH = clampDim((h * 55) / 100, kCurvePopupMinH, kCurvePopupMaxH, h); + const int left = (w - sheetW) / 2; + const int top = (h - sheetH) / 2; + out.sheet = Rect{left, top, left + sheetW, top + sheetH}; + + const int titleBottom = out.sheet.top + kCurvePopupTitleH; + const int closeTop = out.sheet.top + (kCurvePopupTitleH - kCurvePopupCloseSize) / 2; + out.close = Rect{out.sheet.right - kCurvePopupPad - kCurvePopupCloseSize, closeTop, + out.sheet.right - kCurvePopupPad, closeTop + kCurvePopupCloseSize}; + out.title = Rect{out.sheet.left + kCurvePopupPad, out.sheet.top, + out.close.left - kCurvePopupPad, titleBottom}; + + out.curveBox = Rect{out.sheet.left + kCurvePopupPad, titleBottom + 2, + out.sheet.right - kCurvePopupPad, + out.sheet.bottom - kCurvePopupPad}; + return out; +} + +bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y) { + return !contains(layout.sheet, x, y); +} + +} // namespace reasampler::vst diff --git a/src/vst/curve_popup.h b/src/vst/curve_popup.h new file mode 100644 index 0000000..3909b85 --- /dev/null +++ b/src/vst/curve_popup.h @@ -0,0 +1,48 @@ +// curve_popup.h — PURE sheet geometry + dismissal test for the r11 velocity-curve popup +// editor (Wave B, FB1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror +// of overflow_menu: the size-clamp / centering / title-row arithmetic lives here, unit-tested +// at the clamps outside the DAW, while the editor shell draws the wash + sheet through the +// L1 kit and routes clicks (close / curve box / outside-sheet dismiss) via these rects. +// +// THE POPUP (CONTEXT.md §S-VIEW r11). Summoned by the mini curve-preview button, a CENTERED +// SHEET over the Sample face (a 0.50-alpha bg/base wash behind it — lighter than Browse's +// 0.82; a focused sub-editor, not a view change): width clamp(60% of window, 360..520), +// height clamp(55% of window, 260..380). Inside: a ~22px title row ("VELOCITY -> AMP" +// micro-caps left, an 18x18 Close button right) over the full-size curve box filling the +// remainder. The curve box rect here is the BORDER rect — the shell derives the mapping box +// through its ONE curveBoxFromRect formula (the landed inset grammar), so the popup editor +// and the Zone-panel inline editor share coordinates by construction. + +#pragma once + +#include "editor_geometry.h" // Rect, contains + +namespace reasampler::vst { + +// Fixed popup metrics (spec r11), exposed so the shell and tests agree. +inline constexpr int kCurvePopupMinW = 360; +inline constexpr int kCurvePopupMaxW = 520; +inline constexpr int kCurvePopupMinH = 260; +inline constexpr int kCurvePopupMaxH = 380; +inline constexpr int kCurvePopupTitleH = 22; +inline constexpr int kCurvePopupCloseSize = 18; +inline constexpr int kCurvePopupPad = 8; // sheet inner padding (title inset + box margins) + +struct CurvePopupLayout { + Rect sheet; // the bg/panel sheet, centered in the window + Rect title; // the caption text rect (left part of the title row) + Rect close; // the 18x18 Close (x) button, right-anchored in the title row + Rect curveBox; // the full-size curve editor BORDER rect (shell insets via curveBoxFromRect) +}; + +// The popup geometry for a (w x h) window: sheet width clamp(60% w, 360..520) and height +// clamp(55% h, 260..380) — each additionally capped at the window dimension so a degenerate +// window never yields an overhanging sheet — centered; title row + close button at the top; +// the curve box filling the remainder inside kCurvePopupPad margins. Pure. +CurvePopupLayout computeCurvePopup(int w, int h); + +// True when (x, y) lands OUTSIDE the sheet (on the wash) — the click-outside dismissal test. +// The shell additionally gates on "no drag in flight" (spec). Pure. +bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y); + +} // namespace reasampler::vst diff --git a/src/vst/knob_deck.cpp b/src/vst/knob_deck.cpp new file mode 100644 index 0000000..9df5b8c --- /dev/null +++ b/src/vst/knob_deck.cpp @@ -0,0 +1,155 @@ +// knob_deck.cpp — see knob_deck.h. Pure arithmetic; no LICE/VST3/REAPER includes. + +#include "knob_deck.h" + +#include + +namespace reasampler::vst { + +namespace { + +// The knob-row width of a group: cells side by side (no inter-cell gap — the 48px cell +// already carries its own breathing room around the 28px knob), plus the optional row +// toggle after a kDeckToggleGap. +int knobRowWidth(const DeckGroupDesc& g) { + int w = static_cast(g.cellIds.size()) * kDeckCellW; + if (g.rowToggle.id >= 0) { + if (w > 0) w += kDeckToggleGap; + w += 2 * g.rowToggle.segWidth; + } + return w; +} + +// The caption-row width: the caption reserve plus the optional caption toggle. +int captionRowWidth(const DeckGroupDesc& g) { + int w = g.captionWidth; + if (g.captionToggle.id >= 0) w += kDeckToggleGap + 2 * g.captionToggle.segWidth; + return w; +} + +// Place one group's inner geometry given its box. +DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) { + DeckGroupLayout out; + out.id = g.id; + out.box = box; + + const int captionTop = box.top + kDeckGroupPadY; + const int innerLeft = box.left + kDeckGroupPadX; + const int innerRight = box.right - kDeckGroupPadX; + + // Caption row: text left, compact toggle right-anchored (r11 — the not-full-width home). + out.caption = Rect{innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH}; + if (g.captionToggle.id >= 0) { + const int segW = g.captionToggle.segWidth; + const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2; + const Rect seg1{innerRight - segW, togTop, innerRight, togTop + kDeckToggleH}; + const Rect seg0{seg1.left - segW, togTop, seg1.left, togTop + kDeckToggleH}; + out.captionToggle = DeckToggleLayout{g.captionToggle.id, seg0, seg1}; + out.caption.right = seg0.left - kDeckToggleGap; // caption text stops at the toggle + } + + // Knob row: fixed cells left-to-right, then the optional row toggle. + const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap; + int x = innerLeft; + for (int id : g.cellIds) { + DeckCellLayout c; + c.id = id; + c.cell = Rect{x, cellTop, x + kDeckCellW, cellTop + kDeckCellH}; + const int knobLeft = x + (kDeckCellW - kDeckKnobSize) / 2; + const int knobTop = cellTop + 4; + c.knob = Rect{knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize}; + const int labelTop = knobTop + kDeckKnobSize + 4; + c.label = Rect{c.cell.left, labelTop, c.cell.right, labelTop + kDeckCellLabelH}; + out.cells.push_back(c); + x += kDeckCellW; + } + if (g.rowToggle.id >= 0) { + if (!g.cellIds.empty()) x += kDeckToggleGap; + const int segW = g.rowToggle.segWidth; + const int togTop = cellTop + (kDeckCellH - kDeckToggleH) / 2; + const Rect seg0{x, togTop, x + segW, togTop + kDeckToggleH}; + const Rect seg1{seg0.right, togTop, seg0.right + segW, togTop + kDeckToggleH}; + out.rowToggle = DeckToggleLayout{g.rowToggle.id, seg0, seg1}; + } + return out; +} + +} // namespace + +int deckGroupWidth(const DeckGroupDesc& g) { + return (std::max)(captionRowWidth(g), knobRowWidth(g)) + 2 * kDeckGroupPadX; +} + +int deckRowCount(const std::vector& groups, int availWidth) { + if (groups.empty()) return 0; + int rows = 1; + int x = 0; + for (const DeckGroupDesc& g : groups) { + const int w = deckGroupWidth(g); + if (x > 0 && x + kDeckGroupGap + w > availWidth) { + ++rows; + x = w; + } else { + x += (x > 0 ? kDeckGroupGap : 0) + w; + } + } + return rows; +} + +int deckHeight(const std::vector& groups, int availWidth) { + const int rows = deckRowCount(groups, availWidth); + if (rows == 0) return 0; + return rows * kDeckGroupH + (rows - 1) * kDeckRowGap; +} + +DeckLayout layoutDeck(const std::vector& groups, int left, int top, + int availWidth) { + DeckLayout out; + if (groups.empty()) return out; + int x = left; + int y = top; + bool rowHasGroup = false; + out.rowCount = 1; + for (const DeckGroupDesc& g : groups) { + const int w = deckGroupWidth(g); + if (rowHasGroup && (x + kDeckGroupGap + w) > (left + availWidth)) { + // Wrap: whole trailing group onto the next row (mirror of deckRowCount). + ++out.rowCount; + x = left; + y += kDeckGroupH + kDeckRowGap; + rowHasGroup = false; + } + if (rowHasGroup) x += kDeckGroupGap; + const Rect box{x, y, x + w, y + kDeckGroupH}; + out.groups.push_back(layoutGroup(g, box)); + x = box.right; + rowHasGroup = true; + } + out.height = out.rowCount * kDeckGroupH + (out.rowCount - 1) * kDeckRowGap; + return out; +} + +DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) { + for (const DeckGroupLayout& g : layout.groups) { + if (!contains(g.box, x, y)) continue; + if (g.captionToggle.id >= 0) { + if (contains(g.captionToggle.seg0, x, y)) + return {DeckHitKind::CaptionToggle, g.captionToggle.id, 0}; + if (contains(g.captionToggle.seg1, x, y)) + return {DeckHitKind::CaptionToggle, g.captionToggle.id, 1}; + } + if (g.rowToggle.id >= 0) { + if (contains(g.rowToggle.seg0, x, y)) + return {DeckHitKind::RowToggle, g.rowToggle.id, 0}; + if (contains(g.rowToggle.seg1, x, y)) + return {DeckHitKind::RowToggle, g.rowToggle.id, 1}; + } + for (const DeckCellLayout& c : g.cells) { + if (c.id >= 0 && contains(c.cell, x, y)) return {DeckHitKind::Knob, c.id, -1}; + } + return {}; // inside the box but on fence/padding/blank — a miss (groups never overlap) + } + return {}; +} + +} // namespace reasampler::vst diff --git a/src/vst/knob_deck.h b/src/vst/knob_deck.h new file mode 100644 index 0000000..5a1a890 --- /dev/null +++ b/src/vst/knob_deck.h @@ -0,0 +1,133 @@ +// knob_deck.h — PURE knob-deck layout + hit-test for the r11 Sample-face recomposition +// (Wave B, FB1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary, and — like +// param_slider — NO engine types: cells and toggles carry opaque shell-owned control ids. +// The mirror of action_bar / param_slider: the fiddly group-box / caption-row / cell-grid +// arithmetic lives here, unit-tested outside the DAW, while the editor shell draws each +// group (fence, caption, compact toggles, knobs) through the L1 kit and routes clicks/drags +// via the hit-test. The KNOB PRIMITIVE itself (value<->needle-angle, vertical drag) is +// param_slider's (FA4); a knob cell here is just a rect — the shell composes the two. +// +// THE DECK (CONTEXT.md §S-VIEW r11). A horizontal run of FENCED GROUPS, left -> right, each +// a hairline-bordered bg/panel box with a CAPTION ROW (micro-caps caption left; the group's +// compact mode toggle right-anchored IN the caption row — this is where the not-full-width +// toggles live) over a KNOB ROW of fixed 48x58 cells (28px knob centered, 12px label band +// beneath). A group may additionally place one 18px-tall two-segment toggle IN the knob row +// after its cells (the VOICE group's Retrig|Legato — same Mono/Stereo segment grammar, +// vertically centered). Groups that must keep stable geometry across a mode flip reserve +// blank cells (id -1): the AMP ENVELOPE group always spans 5 cells so Gate<->Trigger never +// reflows its neighbours. +// +// WRAP (deterministic): groups place left-to-right with kDeckGroupGap between; a group that +// does not fit the remaining width starts a new deck row (whole groups only, never split). +// The first group of a row always places even if wider than the row (degenerate width). +// deckHeight() exposes the resulting height so the shell can bottom-anchor the deck band and +// give the ELASTIC HERO the rest (r11 band order). + +#pragma once + +#include + +#include "editor_geometry.h" // Rect, contains — the shared geometry idiom + +namespace reasampler::vst { + +// Fixed deck metrics (spec r11), exposed so the shell and tests agree. +inline constexpr int kDeckCellW = 48; // one knob cell +inline constexpr int kDeckCellH = 58; +inline constexpr int kDeckKnobSize = 28; // knob diameter inside the cell +inline constexpr int kDeckCellLabelH = 12; // the Micro label band under the knob +inline constexpr int kDeckCaptionH = 20; // the group caption row +inline constexpr int kDeckToggleH = 18; // compact toggle segment height +inline constexpr int kDeckGroupPadX = 6; // group box horizontal inner padding +inline constexpr int kDeckGroupPadY = 4; // group box vertical inner padding +inline constexpr int kDeckCaptionGap = 2; // caption row -> knob row gap +inline constexpr int kDeckToggleGap = 4; // caption text -> toggle / cells -> row toggle gap +inline constexpr int kDeckGroupGap = 12; // gap between groups on a row +inline constexpr int kDeckRowGap = 8; // gap between wrapped deck rows +// One group box: padding + caption + gap + cell row + padding. +inline constexpr int kDeckGroupH = + kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap + kDeckCellH + kDeckGroupPadY; + +// A two-segment compact toggle (always 2 segments — the Mono/Stereo grammar). id -1 = absent. +struct DeckToggleDesc { + int id = -1; // shell control id returned by the hit-test; -1 = no toggle + int segWidth = 44; // px per segment +}; + +// One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1 +// is a RESERVED BLANK cell (geometry held, never hit — the AMP ENVELOPE Trigger face). +// `captionWidth` is the px the shell reserves for the caption text (this module does not +// measure text — the house constant-metrics pattern). +struct DeckGroupDesc { + int id = 0; // shell group id (opaque here) + int captionWidth = 60; + DeckToggleDesc captionToggle; // right-anchored in the caption row; id -1 = none + std::vector cellIds; // knob cells; -1 = blank reserve + DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none +}; + +// --- Laid-out geometry --------------------------------------------------------------- + +struct DeckToggleLayout { + int id = -1; + Rect seg0; // left segment + Rect seg1; // right segment +}; + +struct DeckCellLayout { + int id = -1; + Rect cell; // the full 48x58 cell + Rect knob; // the centered kDeckKnobSize square (the knob circle inscribes it) + Rect label; // the 12px label band beneath the knob +}; + +struct DeckGroupLayout { + int id = 0; + Rect box; // the fenced group box + Rect caption; // caption text rect (left part of the caption row) + DeckToggleLayout captionToggle; // id -1 when absent (rects empty) + std::vector cells; + DeckToggleLayout rowToggle; // id -1 when absent +}; + +struct DeckLayout { + std::vector groups; + int rowCount = 0; + int height = 0; // rowCount * kDeckGroupH + (rowCount-1) * kDeckRowGap; 0 for no groups +}; + +// The width of one group box: the wider of its caption row (caption + gap + toggle) and its +// knob row (cells + gap + row toggle), plus the horizontal padding. Pure. +int deckGroupWidth(const DeckGroupDesc& g); + +// The number of deck rows the groups occupy at `availWidth` under the greedy whole-group +// wrap (a group that does not fit the remaining row width starts a new row; the first group +// of a row always places). 0 for an empty group list. Pure — the wrap is deterministic. +int deckRowCount(const std::vector& groups, int availWidth); + +// The total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). 0 for an +// empty list. The shell bottom-anchors a band of exactly this height. Pure. +int deckHeight(const std::vector& groups, int availWidth); + +// Lay the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's rule. +// Every rect is absolute. Pure — same inputs, same layout. +DeckLayout layoutDeck(const std::vector& groups, int left, int top, + int availWidth); + +// --- Hit-test -------------------------------------------------------------------------- + +enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle }; + +struct DeckHit { + DeckHitKind kind = DeckHitKind::None; + int id = -1; // the control id of the hit element (cell id / toggle id) + int segment = -1; // 0/1 for a toggle hit; -1 otherwise +}; + +// The deck element a point lands on: a knob CELL (the whole 48x58 cell — friendlier than the +// bare knob circle; the shell anchors the vertical drag wherever the grab lands), a caption- +// toggle segment, or a row-toggle segment. Blank cells (id -1) and everything else miss. +// Pure — the shell's routing entry point. +DeckHit hitTestDeck(const DeckLayout& layout, int x, int y); + +} // namespace reasampler::vst diff --git a/src/vst/master_gain.cpp b/src/vst/master_gain.cpp new file mode 100644 index 0000000..00413e4 --- /dev/null +++ b/src/vst/master_gain.cpp @@ -0,0 +1,51 @@ +// master_gain.cpp — see master_gain.h. Pure math; no LICE/VST3/REAPER includes. + +#include "master_gain.h" + +#include +#include +#include +#include + +namespace reasampler::vst { + +namespace { +double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); } +} // namespace + +double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); } + +double masterGainDbFromNorm(double norm) { + norm = clamp01(norm); + if (norm <= 0.0) return -std::numeric_limits::infinity(); + return kMasterGainMinDb + norm * (kMasterGainMaxDb - kMasterGainMinDb); +} + +double masterGainNormFromDb(double db) { + if (!(db > kMasterGainMinDb)) return 0.0; // -inf, NaN, and the floor all read 0 + return clamp01((db - kMasterGainMinDb) / (kMasterGainMaxDb - kMasterGainMinDb)); +} + +double masterGainLinearFromNorm(double norm) { + norm = clamp01(norm); + if (norm <= 0.0) return 0.0; // TRUE silence at the bottom — not an epsilon + return std::pow(10.0, masterGainDbFromNorm(norm) / 20.0); +} + +double masterGainNormFromLinear(double linear) { + if (!std::isfinite(linear) || linear <= 0.0) return 0.0; + return masterGainNormFromDb(20.0 * std::log10(linear)); +} + +void formatMasterGainLabel(double norm, char* buf, std::size_t len) { + if (!buf || len == 0) return; + norm = clamp01(norm); + if (norm <= 0.0) { + std::snprintf(buf, len, "-inf"); + return; + } + const double db = masterGainDbFromNorm(norm); + std::snprintf(buf, len, "%+.1fdB", db); +} + +} // namespace reasampler::vst diff --git a/src/vst/master_gain.h b/src/vst/master_gain.h new file mode 100644 index 0000000..a1163b7 --- /dev/null +++ b/src/vst/master_gain.h @@ -0,0 +1,54 @@ +// master_gain.h — PURE dB<->linear<->knob-taper math for the FB1 post-mixer master gain. +// NO VST3, NO REAPER, NO SWELL/LICE types. The mirror of trigger_seam: one tiny module owns +// the ONE formula both sides of a seam share — here the editor's Gain knob (normalized 0..1) +// and the processor's stored/applied linear gain — so the drawn needle, the persisted value, +// and the audio-thread multiply can never drift. +// +// THE CONTROL (Daniel, FB1). A post-mixer master gain, range -inf .. +24 dB, dB-scaled taper +// with -inf at the BOTTOM of the knob: normalized 0 maps to TRUE ZERO linear gain (silence, +// not a tiny epsilon), and the remaining travel maps linearly in dB from kMasterGainMinDb +// (the finite taper floor) up to kMasterGainMaxDb. Unity (0 dB) sits at norm +// kMasterGainMinDb/(kMasterGainMinDb - kMasterGainMaxDb) ~= 0.714 — most of the throw is +// usable trim, the last stretch is boost. The PERSISTED value is the LINEAR gain (a plain +// finite double, 0 = silence — no -inf on the wire); the taper is a UI-side view of it. +// +// RT DISCIPLINE: the processor applies the linear gain as one multiply over the summed +// output — these functions run on the UI/state threads only. + +#pragma once + +#include + +namespace reasampler::vst { + +// The dB taper endpoints. norm 0 is -inf (true zero); norm just above 0 starts at the +// finite floor kMasterGainMinDb and sweeps linearly in dB to kMasterGainMaxDb at norm 1. +inline constexpr double kMasterGainMinDb = -60.0; +inline constexpr double kMasterGainMaxDb = 24.0; + +// The largest linear gain the control can produce (kMasterGainMaxDb as a ratio, ~15.849). +double masterGainMaxLinear(); + +// Knob taper: normalized [0,1] -> dB. norm <= 0 -> -infinity; else the linear-in-dB sweep +// [kMasterGainMinDb, kMasterGainMaxDb]. norm is clamped to [0,1]. Pure. +double masterGainDbFromNorm(double norm); + +// Inverse taper: dB -> normalized [0,1]. -infinity (or any dB at/below kMasterGainMinDb) +// maps to the bottom of the finite sweep (0 for -inf, else the clamped floor); +24 -> 1. Pure. +double masterGainNormFromDb(double db); + +// Knob taper composed with dB->ratio: normalized [0,1] -> LINEAR gain. norm 0 -> exactly +// 0.0 (true silence); norm 1 -> masterGainMaxLinear(). Pure. +double masterGainLinearFromNorm(double norm); + +// Inverse: LINEAR gain -> normalized [0,1]. linear <= 0 -> 0 (the -inf bottom); a linear at +// or below the kMasterGainMinDb floor also reads ~0+ (the taper's finite bottom); unity -> +// ~0.714; masterGainMaxLinear() -> 1. Out-of-range/non-finite input clamps. Pure. +double masterGainNormFromLinear(double linear); + +// The knob's hover/drag value label for a normalized value: "-inf" at the bottom, else a +// signed one-decimal dB string ("-12.0dB", "+0.0dB", "+2.4dB"). Writes at most `len` bytes +// including the terminator. Pure. +void formatMasterGainLabel(double norm, char* buf, std::size_t len); + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 32d7664..700c6fe 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -15,10 +15,12 @@ #include "capture_browser.h" #include "capture_paths.h" // resolveBankFile (shared M4 path resolution) #include "component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3) +#include "curve_popup.h" // r11 centered curve-popup sheet geometry (FB1) #include "draw_kit.h" // the L1 draw kit: fillSurface/drawButton/text/drawWaveform (L3) #include "editor_geometry.h" // Rect, contains #include "ext_keys.h" #include "keyboard_strip.h" +#include "master_gain.h" // r11 master-gain dB<->linear<->knob taper (FB1) #include "theme.h" // Role / InteractionState / KitColor / spectralColor (L3) #include "note_entry.h" // S12 direct numeric note-entry parse #include "param_slider.h" // S12/S15/S16 control-surface layout + value<->pixel mapping @@ -56,15 +58,16 @@ constexpr UINT_PTR kSyncTimerId = 1; constexpr UINT kSyncTimerIntervalMs = 500; // Top-level band metrics (shell arithmetic — the load-bearing card/tab/key/zone geometry is in -// capture_browser / keyboard_strip). S-VIEW-2 Sample face (top->bottom): a TITLE band (name + -// Browse/Zone nav buttons), a HERO WAVEFORM band (enlarged — the Simpler/Phase-Plant hero, with -// the S11 markers + the S-VIEW-3 envelope overlay traced over it), a ROOT + PREVIEW cluster -// (fenced root strip + preview-trigger + velocity knob + Mono/Stereo), and the CONTROL STRIP (the -// param panel + keyTrack). Browse + Zone reuse the browser grid / zone strip machinery. +// capture_browser / keyboard_strip / knob_deck). r11 Sample face (top->bottom): a TITLE band +// (name + Browse/Zone nav buttons), the FULL-WIDTH ELASTIC HERO (absorbs all height left after +// the fixed bands, floor kHeroMinHeight — the S11 markers + the S-VIEW-3 envelope overlay trace +// over it), the ROOT + PREVIEW CLUSTER (remainder-width root strip + preview-trigger + radial +// velocity knob + mini curve-preview button + Mono/Stereo), and the bottom-anchored KNOB DECK +// (the fenced control groups — the r11 replacement for the slider control strip). Browse + Zone +// reuse the browser grid / zone strip machinery unchanged. constexpr int kTitleHeight = 26; -constexpr int kHeroWaveformHeight = 150; // the enlarged Sample-face hero (was a 72px strip) -constexpr int kClusterHeight = 52; // root strip + preview + channel toggle -constexpr int kVoiceDeckHeight = 26; // Phase S provisional voice deck band +constexpr int kHeroMinHeight = 150; // the elastic hero's floor (r11) +constexpr int kClusterHeight = 52; // root strip + preview + vel knob + curve btn + channel toggle constexpr int kStripBandHeight = 40; // the keyboard-strip band height (root strip + zone strip) constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons @@ -167,6 +170,10 @@ void ReaSamplerEditor::refreshFromBank() { voiceMode_ = processor_->voiceMode(); monoTrigger_ = processor_->monoTrigger(); if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; + // r11: a refresh that emptied the selection (a bank change on the sync tick) closes the + // curve popup — the empty-state Sample face no longer draws it, and an open-but-invisible + // modal would swallow clicks. + if (selectedId_.empty() && map_.zones.empty()) curvePopupOpen_ = false; // Drop a filter that names a bank no longer present. if (!activeFilterBankId_.empty()) { bool found = false; @@ -467,6 +474,173 @@ double ReaSamplerEditor::previewVelocity01() const { return static_cast(processor_->previewVelocity()) / 127.0; } +// --- r11 knob-deck plumbing (FB1) --------------------------------------------- + +namespace { +// The deck group ids (shell-owned; knob_deck treats them opaquely). Left-to-right deck order. +enum DeckGroup { + kGroupAmpEnv = 0, + kGroupPitch, + kGroupPitchEnv, + kGroupVoice, + kGroupMaster, +}; +} // namespace + +std::vector ReaSamplerEditor::deckGroupDescs(const ZonePlaySeconds& play) const { + // Group widths are MODE-INDEPENDENT: AMP ENVELOPE reserves its 5-cell Gate width (Trigger + // leaves two blank cells), so a Gate<->Trigger flip repopulates in place and never reflows + // the neighbouring groups (r11). The VOICE + MASTER groups are the FB1 homes for the + // provisional voice-deck controls and the new post-mixer gain — the r11 spec predates + // both; surfaced as a judgment call in the handoff. + std::vector out; + { + DeckGroupDesc amp; + amp.id = kGroupAmpEnv; + amp.captionWidth = 78; + amp.captionToggle = {static_cast(ParamControl::kPlayMode), 44}; + if (play.playMode == PlayMode::Gate) { + amp.cellIds = {static_cast(ParamControl::kAttack), + static_cast(ParamControl::kHold), + static_cast(ParamControl::kDecay), + static_cast(ParamControl::kSustain), + static_cast(ParamControl::kRelease)}; + } else { + // Trigger, TIME-ORDERED left-to-right (r11: Fade In · Length % · Fade Out — + // matches the drawn envelope), plus the two reserved blanks. + amp.cellIds = {static_cast(ParamControl::kTrigFadeIn), + static_cast(ParamControl::kTrigLength), + static_cast(ParamControl::kTrigFadeOut), -1, -1}; + } + out.push_back(std::move(amp)); + } + { + DeckGroupDesc pitch; + pitch.id = kGroupPitch; + pitch.captionWidth = 38; + pitch.captionToggle = {static_cast(ParamControl::kPitchEngine), 48}; + pitch.cellIds = {static_cast(ParamControl::kKeyTrack)}; + out.push_back(std::move(pitch)); + } + { + DeckGroupDesc penv; + penv.id = kGroupPitchEnv; + penv.captionWidth = 58; + penv.captionToggle = {static_cast(ParamControl::kPitchEnvEnable), 32}; + penv.cellIds = {static_cast(ParamControl::kPitchEnvAttack), + static_cast(ParamControl::kPitchEnvDecay), + static_cast(ParamControl::kPitchEnvDepth)}; + out.push_back(std::move(penv)); + } + { + DeckGroupDesc voice; + voice.id = kGroupVoice; + voice.captionWidth = 38; + voice.captionToggle = {static_cast(ParamControl::kVoiceMode), 40}; + voice.cellIds = {static_cast(ParamControl::kVoiceCount)}; + voice.rowToggle = {static_cast(ParamControl::kMonoTrigger), 44}; + out.push_back(std::move(voice)); + } + { + DeckGroupDesc master; + master.id = kGroupMaster; + master.captionWidth = 46; + master.cellIds = {static_cast(ParamControl::kMasterGain)}; + out.push_back(std::move(master)); + } + return out; +} + +double ReaSamplerEditor::deckControlNorm(int id, const PerformanceZone& zone) const { + if (id == -2) return previewVelocity01(); // the cluster's preview-velocity knob + switch (static_cast(id)) { + case ParamControl::kKeyTrack: + return clamp01(zone.keyTrack / kKeyTrackMax); + case ParamControl::kVoiceCount: + return clamp01(static_cast(voiceCount_ - kMinVoiceCount) / + static_cast(kMaxVoiceCount - kMinVoiceCount)); + case ParamControl::kMasterGain: + return masterGainNormFromLinear(processor_ ? processor_->masterGainLinear() : 1.0); + default: + return controlValue(id, zone.play); + } +} + +void ReaSamplerEditor::applyDeckKnob(int zoneIndex, int id, double norm) { + if (!processor_) return; + norm = clamp01(norm); + if (id == -2) { + // Preview velocity: live processor write (persisted per-instance; the setter clamps + // to MIDI 1..127 so the knob's bottom still strikes audibly). + processor_->setPreviewVelocity(static_cast(norm * 127.0 + 0.5)); + return; + } + switch (static_cast(id)) { + case ParamControl::kVoiceCount: { + // Stepped: quantize the continuous drag to the integer count and only fire the + // setter on a CHANGE (each fire is an off-thread engine rebuild — cheap, but not + // free; per-step is the right cadence). + const int count = + kMinVoiceCount + + static_cast(norm * (kMaxVoiceCount - kMinVoiceCount) + 0.5); + if (count != voiceCount_) { + voiceCount_ = count; + processor_->setVoiceCount(count); + } + return; + } + case ParamControl::kMasterGain: + // Post-mixer gain: one atomic store; the audio thread picks it up next block. + processor_->setMasterGainLinear(masterGainLinearFromNorm(norm)); + return; + default: + applyZoneControl(zoneIndex, id, norm, 0); + return; + } +} + +std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone) const { + char buf[24]; + buf[0] = '\0'; + const ZonePlaySeconds& play = zone.play; + switch (id == -2 ? ParamControl::kCount : static_cast(id)) { + case ParamControl::kAttack: + snprintf(buf, sizeof(buf), "%.3fs", play.adsr.attackSeconds); break; + case ParamControl::kHold: + snprintf(buf, sizeof(buf), "%.3fs", play.adsr.holdSeconds); break; + case ParamControl::kDecay: + snprintf(buf, sizeof(buf), "%.3fs", play.adsr.decaySeconds); break; + case ParamControl::kSustain: + snprintf(buf, sizeof(buf), "%.0f%%", play.adsr.sustainLevel * 100.0); break; + case ParamControl::kRelease: + snprintf(buf, sizeof(buf), "%.3fs", play.adsr.releaseSeconds); break; + case ParamControl::kTrigLength: + snprintf(buf, sizeof(buf), "%.0f%%", play.trigger.lengthFraction * 100.0); break; + case ParamControl::kTrigFadeIn: + snprintf(buf, sizeof(buf), "%lldf", + static_cast(play.trigger.fadeInFrames)); break; + case ParamControl::kTrigFadeOut: + snprintf(buf, sizeof(buf), "%lldf", + static_cast(play.trigger.fadeOutFrames)); break; + case ParamControl::kPitchEnvAttack: + snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.attackSeconds); break; + case ParamControl::kPitchEnvDecay: + snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.decaySeconds); break; + case ParamControl::kPitchEnvDepth: + snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break; + case ParamControl::kKeyTrack: + snprintf(buf, sizeof(buf), "%.0f%%", zone.keyTrack * 100.0); break; + case ParamControl::kVoiceCount: + snprintf(buf, sizeof(buf), "%d", voiceCount_); break; + case ParamControl::kMasterGain: + formatMasterGainLabel(deckControlNorm(id, zone), buf, sizeof(buf)); break; + default: + // -2 (preview velocity) is labeled at its cluster call site; nothing else here. + break; + } + return std::string(buf); +} + EnvClampBounds ReaSamplerEditor::envClampBounds() const { // Match the control-panel sliders' own domains so a node drag can never produce a param a // slider couldn't (the S-VIEW-F2 invariant). AHDSR seconds cap at kEnvTimeMaxSeconds; the @@ -716,27 +890,35 @@ tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) { namespace { constexpr int kPad = 8; -// The S-VIEW-10 velocity-curve editor box: a compact bordered box beside the hero waveform -// (amp-over-time beside amp-over-velocity — the two share the drawn-curve-with-handles grammar). -// Suppressed gracefully (empty rect) when the window is too narrow for both, mirroring -// prune_button's suppression rule. The INSET keeps node handles + the pick radius inside the -// border so an endpoint at amp 0/1 stays grabbable. -constexpr int kVelCurveBoxW = 168; // the curve box width (incl. border) -constexpr int kVelCurveMinHeroW = 240; // hero must keep at least this much width beside the box +// The S-VIEW-10 velocity-curve editor box metrics. Since r11 the Sample face hosts the curve in +// a POPUP (curve_popup) summoned from the mini preview button; the Zone param panel still draws +// the inline box (FB2 owns Zone parity). The INSET keeps node handles + the pick radius inside +// the border so an endpoint at amp 0/1 stays grabbable — the ONE curveBoxFromRect grammar every +// curve surface (Zone inline box AND the r11 popup) derives its mapping box through. +constexpr int kVelCurveBoxW = 168; // the Zone inline curve box width (incl. border) constexpr int kVelCurveInset = 14; // border -> mapping-box inset: caption band (~12px) + 2px gap constexpr int kCurveDragOffMargin = 24; // release beyond box+margin -> drag-off delete +// The r11 cluster's fixed right-anchored run (left -> right: Preview button, the radial +// preview-velocity knob cell, the mini curve-preview button, Mono|Stereo). +constexpr int kPreviewBtnW = 64; +constexpr int kVelCellW = 48; // the Vel knob cell (deck cell grammar) +constexpr int kCurveBtnSize = 28; // the square curve-preview button + struct SampleBands { Rect title; // top: name + Browse/Zone nav buttons Rect navBrowse; // the "Browse" title-band button Rect navZone; // the "Zone" title-band button - Rect hero; // the hero waveform + S-VIEW-3 envelope overlay - Rect velCurve; // the S-VIEW-10 velocity-curve editor box (empty when suppressed) - Rect cluster; // root strip + preview-trigger + velocity knob + channel toggle - Rect voice; // Phase S voice deck (PROVISIONAL): voice count + Poly/Mono + Retrig/Legato - Rect control; // the param control strip (Mode / Pitch / AHDSR|Trigger / AD pitch / keyTrack) + Rect hero; // the FULL-WIDTH ELASTIC hero waveform + S-VIEW-3 envelope overlay (r11) + Rect cluster; // root strip + preview + vel knob + curve button + channel toggle + Rect deck; // the bottom-anchored knob deck (height from the pure knob_deck wrap) }; -SampleBands computeSampleBands(int w, int h) { +// r11 band order: title (fixed) -> hero (ELASTIC: absorbs all height left after the fixed +// bands, floor kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom- +// anchored). When the window is too short for the floor (below the checkSizeConstraint +// minimum — a defensive case), the hero keeps its floor and the lower bands clip past the +// window bottom gracefully. +SampleBands computeSampleBands(int w, int h, int deckH) { SampleBands b; const int titleH = (std::min)(kTitleHeight, h); b.title = Rect{0, 0, w, titleH}; @@ -748,53 +930,48 @@ SampleBands computeSampleBands(int w, int h) { b.navBrowse = browse; b.navZone = zone; - int y = titleH; - const int heroH = (std::min)(kHeroWaveformHeight, (std::max)(0, h - titleH)); - const Rect fullHero{kPad, y, w - kPad, y + heroH}; - // Carve the velocity-curve editor from the hero band's right when there is room for both; - // otherwise the hero keeps the full width and the curve editor is suppressed (empty rect). - if (fullHero.width() >= kVelCurveBoxW + kPad + kVelCurveMinHeroW) { - b.velCurve = Rect{fullHero.right - kVelCurveBoxW, fullHero.top, fullHero.right, - fullHero.bottom}; - b.hero = Rect{fullHero.left, fullHero.top, b.velCurve.left - kPad, fullHero.bottom}; - } else { - b.hero = fullHero; - b.velCurve = Rect{}; + int deckTop = h - kPad - deckH; + int clusterTop = deckTop - kClusterHeight - 4; + int heroBottom = clusterTop - 4; + if (heroBottom - titleH < kHeroMinHeight) { + heroBottom = titleH + kHeroMinHeight; // hero floor wins; lower bands clip below + clusterTop = heroBottom + 4; + deckTop = clusterTop + kClusterHeight + 4; } - y += heroH; - const int clusterH = (std::min)(kClusterHeight, (std::max)(0, h - y)); - b.cluster = Rect{0, y, w, y + clusterH}; - y += clusterH; - // Phase S voice deck: a narrow PROVISIONAL band between the cluster and the param strip - // (voice count stepper + Poly/Mono + Retrig/Legato). The Wave B recompose owns the final - // placement; carving a distinct band keeps the wiring self-contained and easy to relocate. - const int voiceH = (std::min)(kVoiceDeckHeight, (std::max)(0, h - y)); - b.voice = Rect{0, y, w, y + voiceH}; - y += voiceH; - b.control = Rect{kPad, y, w - kPad, h}; + b.hero = Rect{kPad, titleH, w - kPad, heroBottom}; + b.cluster = Rect{0, clusterTop, w, clusterTop + kClusterHeight}; + b.deck = Rect{kPad, deckTop, w - kPad, deckTop + deckH}; return b; } -// The fenced root keyboard-strip rect inside the cluster band (S-VIEW-2): the LEFT ~55% of the -// cluster, the fenced root affordance promoted from Browse. The preview cluster takes the right. -Rect clusterRootStrip(const Rect& cluster) { +// The r11 cluster sub-rects: the root strip keeps the left side at REMAINDER width; the right +// side is the fixed-width right-anchored run (Preview 64 · Vel knob cell 48 · curve preview +// button 28 · Mono|Stereo). Draw + hit-test both derive from this ONE formula. +struct ClusterRects { + Rect rootStrip; // remainder-width fenced root strip + Rect preview; // the preview-trigger button + Rect velCell; // the radial preview-velocity knob cell (knob + label band) + Rect velKnob; // the 28px knob square at the cell's top + Rect velLabel; // the 12px label band beneath it + Rect curveBtn; // the mini curve-preview button (opens the popup) +}; +ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono) { + ClusterRects r; const int stripTop = cluster.top + (cluster.height() - kStripBandHeight) / 2; - const int right = cluster.left + (cluster.width() * 55) / 100; - return Rect{cluster.left + kPad, stripTop, right - kPad, stripTop + kStripBandHeight}; -} - -// The preview-trigger button rect (right of the root strip, left of the channel toggle). -Rect clusterPreviewButton(const Rect& cluster) { - const Rect strip = clusterRootStrip(cluster); - const int left = strip.right + kPad; - return Rect{left, strip.top, left + 64, strip.bottom}; -} - -// The preview velocity knob rect (a compact horizontal slider next to the preview button). -Rect clusterVelocitySlider(const Rect& cluster) { - const Rect prev = clusterPreviewButton(cluster); - const int left = prev.right + kPad; - return Rect{left, prev.top, left + 96, prev.bottom}; + const int stripBot = stripTop + kStripBandHeight; + const int curveTop = cluster.top + (cluster.height() - kCurveBtnSize) / 2; + r.curveBtn = Rect{chanMono.left - kPad - kCurveBtnSize, curveTop, + chanMono.left - kPad, curveTop + kCurveBtnSize}; + r.velCell = Rect{r.curveBtn.left - kPad - kVelCellW, stripTop, + r.curveBtn.left - kPad, stripBot}; + const int knobLeft = r.velCell.left + (kVelCellW - kDeckKnobSize) / 2; + r.velKnob = Rect{knobLeft, r.velCell.top, knobLeft + kDeckKnobSize, + r.velCell.top + kDeckKnobSize}; + r.velLabel = Rect{r.velCell.left, r.velKnob.bottom, r.velCell.right, r.velCell.bottom}; + r.preview = Rect{r.velCell.left - kPad - kPreviewBtnW, stripTop, + r.velCell.left - kPad, stripBot}; + r.rootStrip = Rect{cluster.left + kPad, stripTop, r.preview.left - kPad, stripBot}; + return r; } // The Zone-view keyboard strip rect. Zone content sits below the "+ Add Zone" affordance @@ -880,41 +1057,49 @@ ChannelToggleRects channelToggleRects(const Rect& area) { return {mono, stereo}; } -// Phase S voice deck (PROVISIONAL — the Wave B recompose owns the final placement): one row of -// per-instance voice-system controls inside the `voice` band. Left to right: a "Voices" label, -// a [-] step-down button, the count readout, a [+] step-up button, then a two-segment -// [Poly|Mono] toggle, then a two-segment [Retrig|Legato] toggle (live only in Mono). Both draw -// + hit-test derive from this ONE formula so they never drift (the channelToggleRects pattern). -constexpr int kVoiceSegW = 52; -constexpr int kVoiceSegH = 18; -constexpr int kVoiceStepW = 18; // the [-] / [+] stepper buttons -constexpr int kVoiceCountW = 30; // the numeric readout between them -constexpr int kVoiceLabelW = 44; // the "Voices" caption -constexpr int kVoiceGap = 16; // gap between the stepper / toggle groups -struct VoiceDeckRects { - Rect label; // "Voices" caption (decorative) - Rect minus; // step the count down - Rect count; // the numeric readout (decorative) - Rect plus; // step the count up - Rect poly; // VoiceMode::Poly segment - Rect mono; // VoiceMode::Mono segment - Rect retrig; // MonoTrigger::Retrigger segment - Rect legato; // MonoTrigger::Legato segment -}; -VoiceDeckRects voiceDeckRects(const Rect& band) { - const int top = band.top + (band.height() - kVoiceSegH) / 2; - const int bot = top + kVoiceSegH; - int x = band.left + kPad; - VoiceDeckRects r; - r.label = Rect{x, top, x + kVoiceLabelW, bot}; x = r.label.right; - r.minus = Rect{x, top, x + kVoiceStepW, bot}; x = r.minus.right + 2; - r.count = Rect{x, top, x + kVoiceCountW, bot}; x = r.count.right + 2; - r.plus = Rect{x, top, x + kVoiceStepW, bot}; x = r.plus.right + kVoiceGap; - r.poly = Rect{x, top, x + kVoiceSegW, bot}; x = r.poly.right; - r.mono = Rect{x, top, x + kVoiceSegW, bot}; x = r.mono.right + kVoiceGap; - r.retrig = Rect{x, top, x + kVoiceSegW, bot}; x = r.retrig.right; - r.legato = Rect{x, top, x + kVoiceSegW, bot}; - return r; +// Draw one radial knob face (r11): the FA4 param_slider primitive owns the value<->angle map; +// this turns it into LICE calls through the kit's palette roles. LICE's arc convention matches +// param_slider's (angle 0 = 12 o'clock, positive clockwise: point = (cx + r*sin(a), cy - +// r*cos(a)), verified in vendor/WDL lice_arc.cpp) — but LICE takes RADIANS, and drawing the +// 7->5 o'clock sweep THROUGH the top needs a continuous angle span, so the degrees convert as +// (deg - 360) * pi/180, mapping 210..510 onto -150..+150 degrees. One conversion, both arcs. +void drawKnobFace(LICE_IBitmap* bmp, const Rect& knobRect, double value01, + InteractionState st) { + const KnobGeometry kg = computeKnob(knobRect); + if (kg.radius <= 1.0) return; + constexpr double kDegToRad = 3.14159265358979323846 / 180.0; + const KnobArc arc{}; // the FA4 default 7->5 o'clock sweep + const float cx = static_cast(kg.centerX); + const float cy = static_cast(kg.centerY); + const float rOuter = static_cast(kg.radius) - 0.5f; + const bool disabled = (st == InteractionState::Disabled); + const bool hot = (st == InteractionState::Dragging || st == InteractionState::Hover); + + // Face: a filled circle in the cell surface color under the interaction state. + LICE_FillCircle(bmp, cx, cy, rOuter - 1.f, toLice(roleColorState(Role::BgCell, st)), + 1.0f, 0, true); + // Track: the full sweep as a hairline arc (the dead 60-degree arc at the bottom stays bare). + const float a0 = static_cast((arc.startDeg - 360.0) * kDegToRad); + const float a1 = static_cast((arc.startDeg + knobSweepDeg(arc) - 360.0) * kDegToRad); + LICE_Arc(bmp, cx, cy, rOuter, a0, a1, toLice(roleColor(Role::LineHairline)), 1.0f, 0, true); + // Value arc: start -> the value's angle, in the live accent (hot while under the pointer / + // dragging, dim when disabled). + const double v = value01 < 0.0 ? 0.0 : (value01 > 1.0 ? 1.0 : value01); + if (v > 0.0) { + const float av = static_cast( + (arc.startDeg + v * knobSweepDeg(arc) - 360.0) * kDegToRad); + const Role valueRole = disabled ? Role::TextDim + : (hot ? Role::AccentHot : Role::AccentPrimary); + LICE_Arc(bmp, cx, cy, rOuter, a0, av, toLice(roleColor(valueRole)), 1.0f, 0, true); + } + // Needle: from ~35% radius out to the rim at the value's angle. + const KnobPoint tip = knobNeedlePoint(kg, arc, v); + const float ix = cx + static_cast((tip.x - kg.centerX) * 0.35); + const float iy = cy + static_cast((tip.y - kg.centerY) * 0.35); + const Role needleRole = disabled ? Role::TextDim : Role::TextPrimary; + LICE_Line(bmp, static_cast(ix + 0.5f), static_cast(iy + 0.5f), + static_cast(tip.x + 0.5f), static_cast(tip.y + 0.5f), + toLice(roleColor(needleRole)), 1.0f, 0, true); } // Draw the pastel spectral keyboard-strip background (Phase L, L3) — the signature surface. @@ -1020,7 +1205,12 @@ void drawTitleBand(LICE_IBitmap* bmp, const Rect& title, const std::string& read } // namespace void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { - const SampleBands bands = computeSampleBands(w, h); + // r11: the deck height comes from the pure knob_deck wrap (mode-independent — the AMP + // ENVELOPE group reserves its 5-cell Gate width, so Gate<->Trigger never changes it). + const PerformanceZone deckZone = effectiveSampleZone(); + const std::vector deckDescs = deckGroupDescs(deckZone.play); + const SampleBands bands = + computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad)); // Title: product name + live readout. Standard B palette — the beta channel gets NO distinct // accent (settled 2026-07-27); the channel-derived vstPluginName is the only beta-vs-stable @@ -1056,7 +1246,7 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { // Nothing loaded yet: the Sample face is the empty state — a "pick a capture" prompt pointing // at Browse (which is lit above). No hero waveform / controls to draw. if (empty) { - Rect body{bands.hero.left, bands.hero.top, bands.hero.right, bands.control.bottom}; + Rect body{bands.hero.left, bands.hero.top, bands.hero.right, bands.deck.bottom}; paintEmptyState(bmp, body); return; } @@ -1064,7 +1254,7 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { // Resolve the effective single-capture zone: the picked id's one-zone override when present, // else the product-default play params (S15-F2 — the single capture is a one-zone map). This // is the ONE storage site both Sample and Zone edit. - PerformanceZone zone = effectiveSampleZone(); + const PerformanceZone& zone = deckZone; // --- Hero waveform band: envelope + S11 markers + S-VIEW-3 envelope overlay ----------- const std::vector& pcm = monoPcmFor(selectedId_); @@ -1108,51 +1298,75 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { kitTextCentered(bmp, waveArea, "(decoding...)", Font::Label, Role::TextDim); } - // S-VIEW-10: the velocity->amp transfer-curve editor beside the hero (amp-over-time beside - // amp-over-velocity). Independent of the decoded PCM — drawn even while (decoding...). - paintVelocityCurve(bmp, bands.velCurve, zone); - - // --- Root + preview cluster (fenced root strip, preview button, velocity knob, channel) --- + // --- Root + preview cluster (r11: remainder-width root strip, preview button, radial + // velocity knob, mini curve-preview button, channel toggle) ----------------------------- fillSurface(bmp, toKitBox(bands.cluster), Role::BgPanel, InteractionState::Rest); + const ChannelToggleRects chan = channelToggleRects(bands.cluster); + const ClusterRects cr = clusterRects(bands.cluster, chan.mono); int root = effectiveRoot(); - const Rect rootStrip = clusterRootStrip(bands.cluster); - drawSpectralStrip(bmp, rootStrip); - { - const StripLayout sl = layoutStrip(rootStrip.width(), rootStrip.height()); - drawRootMarker(bmp, rootStrip, sl, root); + if (cr.rootStrip.width() > 0) { + drawSpectralStrip(bmp, cr.rootStrip); + const StripLayout sl = layoutStrip(cr.rootStrip.width(), cr.rootStrip.height()); + drawRootMarker(bmp, cr.rootStrip, sl, root); } // Preview-trigger button (fires the loaded capture at root through the live voice engine). { - const Rect prev = clusterPreviewButton(bands.cluster); - const KitButtonBox box{toKitBox(prev)}; + const KitButtonBox box{toKitBox(cr.preview)}; const InteractionState st = (previewingNote_ >= 0) ? InteractionState::Active : (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover : InteractionState::Rest); drawButton(bmp, box, "Preview", st, /*warn=*/false); } - // Preview velocity knob (a compact horizontal slider bound to the persisted previewVelocity). + // Preview velocity: a RADIAL knob cell (r11 — the deck cell grammar), bound to the same + // persisted previewVelocity seam. Label swaps to the live value during hover/drag. { - const Rect vs = clusterVelocitySlider(bands.cluster); - const double vel01 = previewVelocity01(); - const Rect track = sliderTrackRect(vs); - fillSurface(bmp, toKitBox(Rect{track.left, track.top + track.height() / 2 - 1, - track.right, track.top + track.height() / 2 + 1}), - Role::BgCell, InteractionState::Pressed); - const Rect handle = sliderHandleRect(vs, vel01); - const int fillW = (std::max)(0, (handle.left + handle.width() / 2) - track.left); - if (fillW > 0) { - LICE_FillRect(bmp, track.left, track.top + track.height() / 2 - 1, fillW, 2, - toLice(roleColor(Role::AccentPrimary)), 1.0f, 0); + const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == -2); + const bool hov = isHovered(HoverKind::kVelKnob, -1); + const InteractionState st = dragging ? InteractionState::Dragging + : (hov ? InteractionState::Hover + : InteractionState::Rest); + drawKnobFace(bmp, cr.velKnob, previewVelocity01(), st); + if (dragging || hov) { + char buf[8]; + snprintf(buf, sizeof(buf), "%d", + static_cast(previewVelocity01() * 127.0 + 0.5)); + kitTextCentered(bmp, cr.velLabel, buf, Font::Micro, Role::TextDim); + } else { + kitTextCentered(bmp, cr.velLabel, "Vel", Font::Micro, Role::TextDim); + } + } + // The mini curve-preview button (r11): a hairline-bordered bg/cell square with the zone's + // live velocity curve traced in miniature (no node markers at this scale). Hover lifts it; + // it draws ACTIVE (accent-primary border) while its popup is open, and re-renders live as + // the popup edits the curve (same zone, re-read each paint). + { + const bool hov = isHovered(HoverKind::kCurveButton, -1); + fillSurface(bmp, toKitBox(cr.curveBtn), Role::BgCell, + hov ? InteractionState::Hover : InteractionState::Rest); + const KitColor border = curvePopupOpen_ ? roleColor(Role::AccentPrimary) + : roleColor(Role::LineHairline); + LICE_DrawRect(bmp, cr.curveBtn.left, cr.curveBtn.top, cr.curveBtn.width() - 1, + cr.curveBtn.height() - 1, toLice(border), 1.0f, 0); + const VelocityCurve& curve = zone.velocityCurve; + const int inset = 3; + const VelocityCurve::Box mini{cr.curveBtn.left + inset, cr.curveBtn.top + inset, + cr.curveBtn.width() - 2 * inset, + cr.curveBtn.height() - 2 * inset}; + if (mini.width > 1 && mini.height > 1) { + const LICE_pixel trace = toLice(roleColor(Role::AccentSecondary)); + int prevX = 0, prevY = 0; + for (int px = 0; px <= mini.width; ++px) { + const int mx = mini.left + px; + const double vel = VelocityCurve::pointFromPixel(mini, mx, mini.top).velocity; + const int my = VelocityCurve::pixelFromPoint(mini, {vel, curve.eval(vel)}).y; + if (px > 0) LICE_Line(bmp, prevX, prevY, mx, my, trace, 1.0f, 0, true); + prevX = mx; + prevY = my; + } } - const KitButtonBox knob{toKitBox(Rect{handle.left, handle.top + 2, handle.right, - handle.bottom - 2})}; - drawButton(bmp, knob, nullptr, InteractionState::Rest, /*warn=*/false); - Rect velLbl{vs.left, vs.top - 12, vs.right, vs.top}; - kitText(bmp, velLbl, "Vel", Font::Micro, Role::TextDim); } // Mono | Stereo output-mode toggle. { - const ChannelToggleRects chan = channelToggleRects(bands.cluster); const bool isStereo = (channelMode_ == ChannelMode::Stereo); const InteractionState monoState = !isStereo ? InteractionState::Active : (isHovered(HoverKind::kChanMono, -1) ? InteractionState::Hover : InteractionState::Rest); @@ -1164,57 +1378,11 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { kitTextCentered(bmp, chan.stereo, "Stereo", Font::Label, isStereo ? Role::BgBase : Role::TextPrimary); } - // --- Phase S voice deck (PROVISIONAL placement; Wave B owns the final composition) ----- - if (bands.voice.height() > 0) { - fillSurface(bmp, toKitBox(bands.voice), Role::BgPanel, InteractionState::Rest); - const VoiceDeckRects vd = voiceDeckRects(bands.voice); - kitTextCentered(bmp, vd.label, "Voices", Font::Micro, Role::TextDim); - // Count stepper: [-] N [+]. The steppers grey out at the range edges (the shared - // pure-core kMin/kMaxVoiceCount — one spelling with the engine + the state bytes). - const bool canDown = voiceCount_ > kMinVoiceCount; - const bool canUp = voiceCount_ < kMaxVoiceCount; - fillSurface(bmp, toKitBox(vd.minus), Role::BgCell, - canDown ? InteractionState::Rest : InteractionState::Disabled); - fillSurface(bmp, toKitBox(vd.plus), Role::BgCell, - canUp ? InteractionState::Rest : InteractionState::Disabled); - kitTextCentered(bmp, vd.minus, "-", Font::Label, - canDown ? Role::TextPrimary : Role::TextDim); - kitTextCentered(bmp, vd.plus, "+", Font::Label, - canUp ? Role::TextPrimary : Role::TextDim); - char countBuf[8]; - snprintf(countBuf, sizeof(countBuf), "%d", voiceCount_); - kitTextCentered(bmp, vd.count, countBuf, Font::ValueMono, Role::TextPrimary); - // Poly | Mono voice-mode toggle (the channel-toggle grammar: active segment lit). - const bool isMono = (voiceMode_ == VoiceMode::Mono); - fillSurface(bmp, toKitBox(vd.poly), Role::BgCell, - !isMono ? InteractionState::Active : InteractionState::Rest); - fillSurface(bmp, toKitBox(vd.mono), Role::BgCell, - isMono ? InteractionState::Active : InteractionState::Rest); - kitTextCentered(bmp, vd.poly, "Poly", Font::Label, - !isMono ? Role::BgBase : Role::TextPrimary); - kitTextCentered(bmp, vd.mono, "Mono", Font::Label, - isMono ? Role::BgBase : Role::TextPrimary); - // Retrig | Legato mono-takeover toggle — meaningful only in Mono; drawn Disabled - // (inert) in Poly so the dependency reads at a glance. - const bool isLegato = (monoTrigger_ == MonoTrigger::Legato); - const InteractionState retrigState = - !isMono ? InteractionState::Disabled - : (!isLegato ? InteractionState::Active : InteractionState::Rest); - const InteractionState legatoState = - !isMono ? InteractionState::Disabled - : (isLegato ? InteractionState::Active : InteractionState::Rest); - fillSurface(bmp, toKitBox(vd.retrig), Role::BgCell, retrigState); - fillSurface(bmp, toKitBox(vd.legato), Role::BgCell, legatoState); - kitTextCentered(bmp, vd.retrig, "Retrig", Font::Label, - !isMono ? Role::TextDim - : (!isLegato ? Role::BgBase : Role::TextPrimary)); - kitTextCentered(bmp, vd.legato, "Legato", Font::Label, - !isMono ? Role::TextDim - : (isLegato ? Role::BgBase : Role::TextPrimary)); - } + // --- The knob deck (r11: the fenced control groups, bottom-anchored) ------------------- + paintKnobDeck(bmp, bands.deck, zone); - // --- The "Modes-and-down" control strip (S-VIEW-2: moved from Zone) -------------------- - paintControls(bmp, bands.control, zone); + // --- The curve popup (r11): a centered sheet over the whole Sample face, drawn LAST ---- + if (curvePopupOpen_) paintCurvePopup(bmp, w, h); } void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, @@ -1236,15 +1404,19 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveA LICE_Line(bmp, x0, poly[i - 1].y, x1, poly[i].y, line, 1.0f, 0, true); } // Draggable node handles: a small square per DRAGGABLE node (Origin + ReleaseStart are draw- - // only). Lit accent-hot when this node is the grabbed one. + // only). Lit accent-hot when this node is the grabbed one. FA2 guarantees every vertex is + // in-bounds (the pre-FA2 right-edge clip is dead and removed — edge nodes like ReleaseEnd + // at area.right-1 MUST get handles); the handle SQUARE is additionally clamped inside the + // hero rect so a 6px box on an edge node never overhangs into the neighbouring bands. const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary)); const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot)); for (const EnvVertex& v : poly) { if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseStart) continue; - if (v.x < waveArea.left || v.x >= waveArea.right) continue; // clipped node — no handle const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node); const int r = 3; - LICE_FillRect(bmp, v.x - r, v.y - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, 0); + const int hx = (std::max)(waveArea.left + r, (std::min)(waveArea.right - 1 - r, v.x)); + const int hy = (std::max)(waveArea.top + r, (std::min)(waveArea.bottom - 1 - r, v.y)); + LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, 0); } } @@ -1305,6 +1477,144 @@ void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, } } +void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, + const PerformanceZone& zone) { + if (deckArea.width() <= 0 || deckArea.height() <= 0) return; + const std::vector descs = deckGroupDescs(zone.play); + const DeckLayout dl = layoutDeck(descs, deckArea.left, deckArea.top, deckArea.width()); + const ZonePlaySeconds& play = zone.play; + const bool isMono = (voiceMode_ == VoiceMode::Mono); + const LICE_pixel hairline = toLice(roleColor(Role::LineHairline)); + + // One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled + // segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance. + const auto drawToggle = [&](const DeckToggleLayout& t, const char* s0, const char* s1, + bool seg1Active, bool disabled) { + const bool hov = !disabled && isHovered(HoverKind::kControl, t.id); + const InteractionState st0 = + disabled ? InteractionState::Disabled + : (!seg1Active ? InteractionState::Active + : (hov ? InteractionState::Hover : InteractionState::Rest)); + const InteractionState st1 = + disabled ? InteractionState::Disabled + : (seg1Active ? InteractionState::Active + : (hov ? InteractionState::Hover : InteractionState::Rest)); + fillSurface(bmp, toKitBox(t.seg0), Role::BgCell, st0); + fillSurface(bmp, toKitBox(t.seg1), Role::BgCell, st1); + kitTextCentered(bmp, t.seg0, s0, Font::Micro, + disabled ? Role::TextDim + : (!seg1Active ? Role::BgBase : Role::TextPrimary)); + kitTextCentered(bmp, t.seg1, s1, Font::Micro, + disabled ? Role::TextDim + : (seg1Active ? Role::BgBase : Role::TextPrimary)); + }; + + // The knob's short name label (swapped for the live value during hover/drag — r11: no + // third line, no permanent value clutter). + const auto knobName = [](ParamControl c) -> const char* { + switch (c) { + 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::kTrigFadeIn: return "Fade In"; + case ParamControl::kTrigLength: return "Len %"; + case ParamControl::kTrigFadeOut: return "Fade Out"; + case ParamControl::kKeyTrack: return "Key Trk"; + case ParamControl::kPitchEnvAttack: return "P.Att"; + case ParamControl::kPitchEnvDecay: return "P.Dec"; + case ParamControl::kPitchEnvDepth: return "P.Depth"; + case ParamControl::kVoiceCount: return "Voices"; + case ParamControl::kMasterGain: return "Gain"; + default: return ""; + } + }; + + for (const DeckGroupLayout& g : dl.groups) { + // The fence: a bg/panel box with a hairline border, caption micro-caps left. + fillSurface(bmp, toKitBox(g.box), Role::BgPanel, InteractionState::Rest); + LICE_DrawRect(bmp, g.box.left, g.box.top, g.box.width() - 1, g.box.height() - 1, + hairline, 1.0f, 0); + const char* caption = ""; + switch (g.id) { + case kGroupAmpEnv: caption = "AMP ENVELOPE"; break; + case kGroupPitch: caption = "PITCH"; break; + case kGroupPitchEnv: caption = "PITCH ENV"; break; + case kGroupVoice: caption = "VOICE"; break; + case kGroupMaster: caption = "MASTER"; break; + default: break; + } + kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim); + + // The compact caption toggle (r11: right-anchored IN the caption row, never full-width). + if (g.captionToggle.id >= 0) { + switch (static_cast(g.captionToggle.id)) { + case ParamControl::kPlayMode: + drawToggle(g.captionToggle, "Gate", "Trigger", + play.playMode == PlayMode::Trigger, false); + break; + case ParamControl::kPitchEngine: + drawToggle(g.captionToggle, "Varisp", "Presrv", + play.pitchEngine == PitchEngine::Preserve, false); + break; + case ParamControl::kPitchEnvEnable: + drawToggle(g.captionToggle, "Off", "On", play.pitchEnv.enabled, false); + break; + case ParamControl::kVoiceMode: + drawToggle(g.captionToggle, "Poly", "Mono", isMono, false); + break; + default: break; + } + } + // The row toggle (VOICE group's Retrig|Legato) — live only in Mono. + if (g.rowToggle.id >= 0) { + drawToggle(g.rowToggle, "Retrig", "Legato", + monoTrigger_ == MonoTrigger::Legato, !isMono); + } + + // The knobs. PITCH ENV knobs draw Disabled (not hidden) while the envelope is off — + // stable geometry (r11). + for (const DeckCellLayout& c : g.cells) { + if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares) + const bool disabled = (g.id == kGroupPitchEnv && !play.pitchEnv.enabled); + const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == c.id); + const bool hov = !disabled && isHovered(HoverKind::kControl, c.id); + const InteractionState st = + disabled ? InteractionState::Disabled + : (dragging ? InteractionState::Dragging + : (hov ? InteractionState::Hover : InteractionState::Rest)); + drawKnobFace(bmp, c.knob, deckControlNorm(c.id, zone), st); + const std::string label = (dragging || hov) + ? deckValueLabel(c.id, zone) + : std::string(knobName(static_cast(c.id))); + kitTextCentered(bmp, c.label, label.c_str(), Font::Micro, Role::TextDim); + } + } +} + +void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) { + // The 0.50-alpha bg/base wash (lighter than Browse's 0.82 — a focused sub-editor; the + // Sample face stays legible behind it), then the centered sheet. + LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.50f, 0); + const CurvePopupLayout pl = computeCurvePopup(w, h); + fillSurface(bmp, toKitBox(pl.sheet), Role::BgPanel, InteractionState::Rest); + LICE_DrawRect(bmp, pl.sheet.left, pl.sheet.top, pl.sheet.width() - 1, + pl.sheet.height() - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0); + kitText(bmp, pl.title, "VELOCITY -> AMP", Font::Micro, Role::TextDim); + { + const KitButtonBox box{toKitBox(pl.close)}; + const InteractionState st = isHovered(HoverKind::kPopupClose, -1) + ? InteractionState::Hover + : InteractionState::Rest; + drawButton(bmp, box, "x", st, /*warn=*/false); + } + // The full-size editor: the SAME draw path as the Zone inline box (paintVelocityCurve + + // the one curveBoxFromRect mapping formula), so trace/handles/drag-off cues cannot drift + // between the two surfaces. The popup edits the picked capture's one-zone site. + paintVelocityCurve(bmp, pl.curveBox, effectiveSampleZone()); +} + void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y) { if (zoneIndex < 0 || zoneIndex >= static_cast(map_.zones.size())) return; const VelocityCurve::Box box = curveBoxFromRect(r); @@ -1804,8 +2114,22 @@ void ReaSamplerEditor::resolveHover(int x, int y) { if (id >= 0) h = {HoverKind::kControl, id}; } } - } else { // Sample view (home) - const SampleBands bands = computeSampleBands(w, hgt); + } else if (curvePopupOpen_) { // Sample view with the r11 curve popup open (modal sheet) + const CurvePopupLayout pl = computeCurvePopup(w, hgt); + if (contains(pl.close, x, y)) { + h = {HoverKind::kPopupClose, -1}; + } else if (contains(pl.curveBox, x, y)) { + // A curve node under the pointer lights accent-hot (mirror of the inline box). + const PerformanceZone zone = effectiveSampleZone(); + const int idx = + zone.velocityCurve.pointAtPixel(curveBoxFromRect(pl.curveBox), x, y); + if (idx >= 0) h = {HoverKind::kCurveNode, idx}; + } + } else { // Sample view (home, r11 recomposition) + const PerformanceZone zone = effectiveSampleZone(); + const std::vector descs = deckGroupDescs(zone.play); + const SampleBands bands = + computeSampleBands(w, hgt, deckHeight(descs, w - 2 * kPad)); if (contains(bands.navBrowse, x, y)) { h = {HoverKind::kNavBrowse, -1}; } else if (contains(bands.navZone, x, y)) { @@ -1814,21 +2138,18 @@ void ReaSamplerEditor::resolveHover(int x, int y) { // Empty state — no interactive surfaces beyond the nav. } else { const ChannelToggleRects chan = channelToggleRects(bands.cluster); - if (contains(clusterPreviewButton(bands.cluster), x, y)) h = {HoverKind::kPreview, -1}; + const ClusterRects cr = clusterRects(bands.cluster, chan.mono); + if (contains(cr.preview, x, y)) h = {HoverKind::kPreview, -1}; + else if (contains(cr.velCell, x, y)) h = {HoverKind::kVelKnob, -1}; + else if (contains(cr.curveBtn, x, y)) h = {HoverKind::kCurveButton, -1}; else if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1}; else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1}; - else if (bands.velCurve.width() > 0 && contains(bands.velCurve, x, y)) { - // S-VIEW-10: a curve node under the pointer lights accent-hot. - const PerformanceZone zone = effectiveSampleZone(); - const int idx = zone.velocityCurve.pointAtPixel( - curveBoxFromRect(bands.velCurve), x, y); - if (idx >= 0) h = {HoverKind::kCurveNode, idx}; - } else { - const PerformanceZone zone = effectiveSampleZone(); - const std::vector descs = controlDescs(zone.play); - const std::vector rows = layoutControls(bands.control, descs); - const int id = controlAtPoint(rows, x, y); - if (id >= 0) h = {HoverKind::kControl, id}; + else if (contains(bands.deck, x, y)) { + // A deck knob/toggle under the pointer: knobs light + swap label->value. + const DeckLayout dl = + layoutDeck(descs, bands.deck.left, bands.deck.top, bands.deck.width()); + const DeckHit dh = hitTestDeck(dl, x, y); + if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id}; } } } @@ -1917,9 +2238,35 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { return; } - // ---- Sample home (S-VIEW-2) ---- + // ---- Sample home (S-VIEW-2 / r11) ---- if (view_ == View::kSample) { - const SampleBands bands = computeSampleBands(w, h); + // r11 curve popup: while open the sheet is modal over the Sample face — it owns every + // left-click. Close click / outside-wash click dismiss (outside only when no drag is + // in flight, per the spec); in-box clicks route to the SAME curve machinery the inline + // Zone editor uses; anything else on the sheet is swallowed. + if (curvePopupOpen_) { + const CurvePopupLayout pl = computeCurvePopup(w, h); + if (contains(pl.close, x, y)) { + curvePopupOpen_ = false; + invalidate(); + return; + } + if (contains(pl.curveBox, x, y)) { + const int zi = ensureSampleZone(); + if (zi >= 0) handleCurveMouseDown(pl.curveBox, zi, x, y); + return; + } + if (popupOutsideSheet(pl, x, y) && drag_ == DragKind::kNone) { + curvePopupOpen_ = false; + invalidate(); + } + return; + } + + const PerformanceZone probeZone = effectiveSampleZone(); + const std::vector deckDescs = deckGroupDescs(probeZone.play); + const SampleBands bands = + computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad)); if (contains(bands.navBrowse, x, y)) { // Open the Browse modal; seed its pending pick from the loaded id so the current // capture reads as pre-selected. @@ -1932,9 +2279,12 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { if (contains(bands.navZone, x, y)) { view_ = View::kZone; invalidate(); return; } if (selectedId_.empty() && map_.zones.empty()) return; // empty state — nav only + const ChannelToggleRects chan = channelToggleRects(bands.cluster); + const ClusterRects cr = clusterRects(bands.cluster, chan.mono); + // Preview-trigger button: fire the loaded capture at its root through the voice engine // (momentary — note-on on press, note-off on release). - if (contains(clusterPreviewButton(bands.cluster), x, y)) { + if (contains(cr.preview, x, y)) { const int note = effectiveRoot(); if (previewingNote_ >= 0) processor_->previewNoteOff(previewingNote_); previewingNote_ = note; @@ -1942,22 +2292,25 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { invalidate(); return; } - // Preview velocity knob: grab to drag (a kParamSlider drag against the velocity domain, - // marked by dragParamId_ == -2 sentinel so onMouseMove routes it to setPreviewVelocity). - { - const Rect vs = clusterVelocitySlider(bands.cluster); - if (contains(vs, x, y)) { - drag_ = DragKind::kParamSlider; - dragParamId_ = -2; // sentinel: the preview velocity knob (not a zone param) - dragParamPanel_ = vs; - const double v = valueAtPoint(vs, x); - processor_->setPreviewVelocity(static_cast(v * 127.0 + 0.5)); - invalidate(); - return; - } + // Radial preview-velocity knob (r11): GRAB-ANCHORED vertical drag — the grab itself + // never jumps the value (FA4); the delta from the grab point maps via knobDragValue. + if (contains(cr.velCell, x, y)) { + drag_ = DragKind::kDeckKnob; + dragParamId_ = -2; // sentinel: the preview velocity knob (a processor param) + dragParamZone_ = -1; + dragKnobStartValue_ = previewVelocity01(); + dragStartX_ = x; + dragStartY_ = y; + invalidate(); + return; + } + // The mini curve-preview button: summon the popup editor. + if (contains(cr.curveBtn, x, y)) { + curvePopupOpen_ = true; + invalidate(); + return; } // Channel toggle. - const ChannelToggleRects chan = channelToggleRects(bands.cluster); if (contains(chan.mono, x, y)) { channelMode_ = ChannelMode::Mono; processor_->setChannelMode(ChannelMode::Mono); @@ -1971,45 +2324,82 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { return; } - // Phase S voice deck (PROVISIONAL). Every edit writes through the processor setter, - // which rebuilds the engine off-thread via the drain-slot swap (ringing tails survive); - // the local snapshot updates in step so the deck repaints without waiting for a sync tick. - if (bands.voice.height() > 0 && contains(bands.voice, x, y)) { - const VoiceDeckRects vd = voiceDeckRects(bands.voice); - if (contains(vd.minus, x, y) && voiceCount_ > kMinVoiceCount) { - voiceCount_ -= 1; - processor_->setVoiceCount(voiceCount_); - invalidate(); - } else if (contains(vd.plus, x, y) && voiceCount_ < kMaxVoiceCount) { - voiceCount_ += 1; - processor_->setVoiceCount(voiceCount_); - invalidate(); - } else if (contains(vd.poly, x, y)) { - voiceMode_ = VoiceMode::Poly; - processor_->setVoiceMode(VoiceMode::Poly); - invalidate(); - } else if (contains(vd.mono, x, y)) { - voiceMode_ = VoiceMode::Mono; - processor_->setVoiceMode(VoiceMode::Mono); - invalidate(); - } else if (voiceMode_ == VoiceMode::Mono && contains(vd.retrig, x, y)) { - monoTrigger_ = MonoTrigger::Retrigger; // inert (Disabled) in Poly - processor_->setMonoTrigger(MonoTrigger::Retrigger); - invalidate(); - } else if (voiceMode_ == VoiceMode::Mono && contains(vd.legato, x, y)) { - monoTrigger_ = MonoTrigger::Legato; - processor_->setMonoTrigger(MonoTrigger::Legato); + // The knob deck (r11): toggles commit at once (a discrete, final edit — the slider + // precedent); knobs start a grab-anchored vertical drag. The deck band swallows its + // clicks (no fall-through to the hero/markers). + if (contains(bands.deck, x, y)) { + const DeckLayout dl = layoutDeck(deckDescs, bands.deck.left, bands.deck.top, + bands.deck.width()); + const DeckHit hit = hitTestDeck(dl, x, y); + if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) { + switch (static_cast(hit.id)) { + case ParamControl::kVoiceMode: { + // Processor-side per-instance param: live setter (engine rebuild via + // the drain-slot swap — tails survive), local snapshot in step. + const VoiceMode m = + (hit.segment == 1) ? VoiceMode::Mono : VoiceMode::Poly; + if (m != voiceMode_) { + voiceMode_ = m; + processor_->setVoiceMode(m); + } + invalidate(); + break; + } + case ParamControl::kMonoTrigger: { + if (voiceMode_ != VoiceMode::Mono) break; // Disabled (inert) in Poly + const MonoTrigger t = + (hit.segment == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger; + if (t != monoTrigger_) { + monoTrigger_ = t; + processor_->setMonoTrigger(t); + } + invalidate(); + break; + } + default: { + // Zone-param toggles (play mode / pitch engine / pitch-env enable): + // materialize the one-zone site, apply, commit. + const int zi = ensureSampleZone(); + if (zi >= 0) { + applyZoneControl(zi, hit.id, 0.0, hit.segment); + selectedZone_ = zi; + commitAndReload(); + } + break; + } + } + return; + } + if (hit.kind == DeckHitKind::Knob) { + // PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off. + const bool pitchEnvKnob = + hit.id == static_cast(ParamControl::kPitchEnvAttack) || + hit.id == static_cast(ParamControl::kPitchEnvDecay) || + hit.id == static_cast(ParamControl::kPitchEnvDepth); + if (pitchEnvKnob && !probeZone.play.pitchEnv.enabled) return; + if (hit.id == static_cast(ParamControl::kVoiceCount) || + hit.id == static_cast(ParamControl::kMasterGain)) { + // Processor-side knobs: transient live writes, no map edit, no reload. + drag_ = DragKind::kDeckKnob; + dragParamId_ = hit.id; + dragParamZone_ = -1; + dragKnobStartValue_ = deckControlNorm(hit.id, probeZone); + } else { + // Zone-param knobs: live-drag the map, commit on release. + const int zi = ensureSampleZone(); + if (zi < 0) return; + drag_ = DragKind::kDeckKnob; + dragParamId_ = hit.id; + dragParamZone_ = zi; + selectedZone_ = zi; + dragStartMap_ = map_; + dragKnobStartValue_ = + deckControlNorm(hit.id, map_.zones[static_cast(zi)]); + } + dragStartX_ = x; + dragStartY_ = y; invalidate(); } - return; // the deck band swallows its clicks (no fall-through to the hero/markers) - } - - // S-VIEW-10: the velocity-curve editor beside the hero. Every in-box click is an edit - // (grab / Alt-delete / add-at-cursor), so materialize the one-zone site first (the - // mirror of the control strip's ensureSampleZone path). - if (bands.velCurve.width() > 0 && contains(bands.velCurve, x, y)) { - const int zi = ensureSampleZone(); - if (zi >= 0) handleCurveMouseDown(bands.velCurve, zi, x, y); return; } @@ -2051,28 +2441,17 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { } } - // Fenced root strip: grab the root marker. - const Rect rootStrip = clusterRootStrip(bands.cluster); - const StripLayout sl = layoutStrip(rootStrip.width(), rootStrip.height()); - const int note = keyAtPoint(sl, x - rootStrip.left, y - rootStrip.top); - if (note >= 0) { - drag_ = DragKind::kRootMarker; - dragStartX_ = x; - dragStartRoot_ = note; - dragStartMap_ = map_; - onMouseMove(x, y); // apply the click as the first delta==0 set - return; - } - - // The control strip (S-VIEW-2 moved from Zone): route via the shared handler on the - // effective one-zone site (materialize it on first interaction, mirror of the Zone path). - { - const PerformanceZone probeZone = effectiveSampleZone(); - const std::vector descs = controlDescs(probeZone.play); - const std::vector rows = layoutControls(bands.control, descs); - if (controlAtPoint(rows, x, y) >= 0) { - const int zi = ensureSampleZone(); - if (zi >= 0) handleControlClick(zi, bands.control, x, y); + // Fenced root strip: grab the root marker (remainder-width since r11). + if (cr.rootStrip.width() > 0) { + const StripLayout sl = layoutStrip(cr.rootStrip.width(), cr.rootStrip.height()); + const int note = keyAtPoint(sl, x - cr.rootStrip.left, y - cr.rootStrip.top); + if (note >= 0) { + drag_ = DragKind::kRootMarker; + dragStartX_ = x; + dragStartRoot_ = note; + dragStartMap_ = map_; + onMouseMove(x, y); // apply the click as the first delta==0 set + return; } } return; @@ -2244,18 +2623,31 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { if (drag_ == DragKind::kNone) return; dragCurX_ = x; // keep the live cursor position for drag-state draw cues (e.g. drag-off warn) dragCurY_ = y; - RECT cr{}; - GetClientRect(childHwnd_, &cr); - const int w = cr.right - cr.left; - const int h = cr.bottom - cr.top; - const SampleBands bands = computeSampleBands(w, h); + RECT rc{}; + GetClientRect(childHwnd_, &rc); + const int w = rc.right - rc.left; + const int h = rc.bottom - rc.top; + // r11: the Sample bands derive from the deck height (mode-independent width math). + const std::vector deckDescs = deckGroupDescs(effectiveSampleZone().play); + const SampleBands bands = computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad)); const int dx = x - dragStartX_; + if (drag_ == DragKind::kDeckKnob) { + // r11 radial knob: GRAB-ANCHORED vertical drag — knobDragValue maps the y delta from + // the value at grab (up = increase), so the value tracks relative motion and never + // jumps on grab (FA4). Live feedback; zone-param commits land on WM_LBUTTONUP. + const int dy = y - dragStartY_; + applyDeckKnob(dragParamZone_, dragParamId_, knobDragValue(dragKnobStartValue_, dy)); + invalidate(); + return; + } + if (drag_ == DragKind::kRootMarker) { // The fenced root strip on the Sample cluster band. Setting the root materializes a // full-keyboard zone carrying the override on the picked id (the D-B override vehicle) — // upsert by id so a repeated drag edits the same zone rather than stacking duplicates. - const Rect stripArea = clusterRootStrip(bands.cluster); + const ChannelToggleRects chan = channelToggleRects(bands.cluster); + const Rect stripArea = clusterRects(bands.cluster, chan.mono).rootStrip; const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); const int note = resolveDragNote(sl, dragStartRoot_, dx); bool found = false; @@ -2378,16 +2770,9 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { } if (drag_ == DragKind::kParamSlider) { - // The preview-velocity knob (Sample face) uses the -2 sentinel — map x->0..1 over the - // stored knob rect and write it to the processor (persisted per-instance). - if (dragParamId_ == -2) { - const double v = valueAtPoint(dragParamPanel_, x); - if (processor_) processor_->setPreviewVelocity(static_cast(v * 127.0 + 0.5)); - invalidate(); - return; - } - // S12/S15/S16 + keyTrack: re-lay the panel and map x -> value against the grabbed control's - // live track rect. Uses dragParamZone_ (the Sample face has no selectedZone_ coupling). + // S12/S15/S16 + keyTrack (Zone surface only since r11 — the Sample face's controls are + // deck knobs): re-lay the panel and map x -> value against the grabbed control's live + // track rect. Uses dragParamZone_. const int zi = dragParamZone_; if (zi < 0 || zi >= static_cast(map_.zones.size())) return; const ZonePlaySeconds& play = map_.zones[static_cast(zi)].play; @@ -2445,10 +2830,16 @@ void ReaSamplerEditor::onMouseUp(int x, int y) { dragParamZone_ = -1; curvePointIndex_ = -1; dragCurveZone_ = -1; - // A scrollbar drag is transient UI (no map change), and the preview-velocity knob (id==-2) is a - // processor-side per-instance setting already applied live — neither reloads the instrument. - // Every other drag is a coherent map edit: publish the in-flight map + reload off-thread. - if (kind == DragKind::kScrollThumb || (kind == DragKind::kParamSlider && paramId == -2)) { + // A scrollbar drag is transient UI (no map change), and the processor-side knobs (the + // preview-velocity -2 sentinel, voice count, master gain) are per-instance settings already + // applied live — none reloads the instrument here (voice count rebuilds per step in its + // setter; master gain is an atomic the audio thread reads directly). Every other drag is a + // coherent map edit: publish the in-flight map + reload off-thread. + const bool deckTransient = + kind == DragKind::kDeckKnob && + (paramId == -2 || paramId == static_cast(ParamControl::kVoiceCount) || + paramId == static_cast(ParamControl::kMasterGain)); + if (kind == DragKind::kScrollThumb || deckTransient) { invalidate(); return; } @@ -2470,6 +2861,29 @@ void ReaSamplerEditor::onMouseUp(int x, int y) { commitAndReload(); } +void ReaSamplerEditor::onMouseRDown(int x, int y) { + // r11 (issue 3c): right-click on a popup curve node deletes it — the PRIMARY delete + // affordance; Alt-click and drag-off remain as landed alternates. Commits immediately + // through the same path as Alt-click; deletePoint's endpoint guard makes an endpoint + // right-click a safe no-op. Right-clicks act ONLY while the popup is open (nothing else + // in the editor consumes them), and never during an in-flight left drag. + if (!processor_ || view_ != View::kSample || !curvePopupOpen_) return; + if (drag_ != DragKind::kNone) return; + RECT rc{}; + GetClientRect(childHwnd_, &rc); + const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top); + if (!contains(pl.curveBox, x, y)) return; + const int zi = ensureSampleZone(); + if (zi < 0) return; + PerformanceZone& z = map_.zones[static_cast(zi)]; + const int idx = z.velocityCurve.pointAtPixel(curveBoxFromRect(pl.curveBox), x, y); + if (idx >= 0 && z.velocityCurve.deletePoint(static_cast(idx))) { + selectedZone_ = zi; + hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node + commitAndReload(); + } +} + void ReaSamplerEditor::onMouseWheel(int delta) { // Browser scroll (only in the Browse modal — the sole card grid). One wheel notch // (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A positive @@ -2483,6 +2897,14 @@ void ReaSamplerEditor::onMouseWheel(int delta) { } void ReaSamplerEditor::onSearchChar(unsigned int ch) { + // r11 curve popup: Esc dismisses (checked first — the popup is modal over the Sample face, + // where neither the note-entry fields nor the Browse search can hold focus). + if (curvePopupOpen_ && ch == 27) { + curvePopupOpen_ = false; + invalidate(); + return; + } + // S12 numeric note-entry (Zone surface): 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). @@ -2612,6 +3034,13 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, ReleaseCapture(); } return 0; + case WM_RBUTTONDOWN: + // r11: right-click — the curve popup's primary node-delete affordance (issue 3c). + // Routed explicitly (the child wndproc historically handled only left-button). + if (self) self->onMouseRDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + return 0; + case WM_RBUTTONUP: + return 0; // claimed so the pair never reaches DefWindowProc (no context menu) case WM_CAPTURECHANGED: // Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore map_ to its // pre-grab snapshot so the in-flight live-drag mutation is rolled back, then reset @@ -2626,11 +3055,15 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, self->invalidate(); } if (self->drag_ != DragKind::kNone) { - // A scrollbar drag + the preview-velocity knob (kParamSlider id==-2) are transient - // (no map mutation; dragStartMap_ not snapshotted) — reset drag state only, never - // touch map_. Every map-editing drag rolls its live mutation back to the snapshot. + // A scrollbar drag + the processor-side deck knobs (preview velocity -2 / + // voice count / master gain) are transient (no map mutation; dragStartMap_ + // not snapshotted) — reset drag state only, never touch map_. Every + // map-editing drag rolls its live mutation back to the snapshot. const bool transient = self->drag_ == DragKind::kScrollThumb || - (self->drag_ == DragKind::kParamSlider && self->dragParamId_ == -2); + (self->drag_ == DragKind::kDeckKnob && + (self->dragParamId_ == -2 || + self->dragParamId_ == static_cast(ParamControl::kVoiceCount) || + self->dragParamId_ == static_cast(ParamControl::kMasterGain))); if (!transient) self->map_ = self->dragStartMap_; self->drag_ = DragKind::kNone; self->dragParamId_ = -1; diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index 73d57fb..1b89a1d 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -33,7 +33,8 @@ #include "editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules) #include "envelope_edit.h" // EnvClampBounds / NodeHit (S-VIEW-3 envelope node hit-test/edit) #include "envelope_overlay.h" // AmpEnvelope / EnvNode (S-VIEW-3 envelope overlay draw seam) -#include "param_slider.h" // ControlRow (the S12/S15/S16 control-surface geometry) +#include "knob_deck.h" // DeckGroupDesc / DeckLayout (r11 Sample-face knob deck, FB1) +#include "param_slider.h" // ControlRow + the FA4 radial-knob primitive (S12/S15/S16 + r11) #include "peaks.h" // Envelope (the cached peak thumbnail) #include "sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot) #include "velocity_curve.h" // VelocityCurve (S-VIEW-10 transfer-curve editor state) @@ -80,9 +81,11 @@ private: // marker on the S11 waveform surface (which marker is in waveMarker_); kEnvNode is a // draggable envelope breakpoint on the Sample-view hero overlay (S-VIEW-3, which node in // envNode_); kCurveNode is a draggable velocity-curve control point in the S-VIEW-10 - // transfer-curve editor (which point in curvePointIndex_). + // transfer-curve editor (which point in curvePointIndex_); kDeckKnob is a GRAB-ANCHORED + // vertical radial-knob drag on the r11 Sample-face deck/cluster (which control in + // dragParamId_; the value at grab in dragKnobStartValue_ — no jump on grab, FA4). enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker, - kScrollThumb, kParamSlider, kEnvNode, kCurveNode }; + kScrollThumb, kParamSlider, kEnvNode, kCurveNode, kDeckKnob }; // 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 @@ -103,6 +106,12 @@ private: kPitchEnvDecay, // AD pitch decay (S16) kPitchEnvDepth, // AD pitch depth in +/- semitones (S16) kKeyTrack, // S-VIEW-6 key-tracking 0..200% (lives on PerformanceZone, not ZonePlaySeconds) + // r11 deck-only controls (FB1): processor-side per-instance params, NOT zone params — + // routed to the processor setters, never through applyZoneControl / the map. + kVoiceCount, // Phase S polyphony bound (1..32) — a stepped knob in the VOICE group + kVoiceMode, // Poly | Mono caption toggle (VOICE group) + kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono) + kMasterGain, // FB1 post-mixer master gain knob (-inf..+24 dB taper, MASTER group) kCount }; @@ -134,8 +143,11 @@ private: kPreview, // the Sample-view preview-trigger button kAddZone, // the "+ Add Zone" button kDeleteZone, // the "Delete" zone button - kControl, // a param-panel control row (index = ControlDesc id) + kControl, // a param-panel control row / deck element (index = control id) kCurveNode, // a velocity-curve control point (index = point index, S-VIEW-10) + kVelKnob, // the cluster preview-velocity radial knob (r11) + kCurveButton, // the cluster mini curve-preview button (r11 — opens the popup) + kPopupClose, // the curve popup's Close (x) button (r11) }; struct HoverTarget { HoverKind kind = HoverKind::kNone; @@ -146,11 +158,19 @@ private: #ifdef _WIN32 void paint(HDC hdc); - void paintSample(LICE_IBitmap* bmp, int w, int h); // S-VIEW-2 home face + void paintSample(LICE_IBitmap* bmp, int w, int h); // S-VIEW-2/r11 home face void paintBrowse(LICE_IBitmap* bmp, int w, int h); // S-VIEW-5 modal picker overlay void paintZone(LICE_IBitmap* bmp, int w, int h); // S-VIEW-8 zone surface void paintEmptyState(LICE_IBitmap* bmp, const Rect& area); - void paintControls(LICE_IBitmap* bmp, const Rect& panel, const PerformanceZone& zone); // S12/S15/S16 + keyTrack + void paintControls(LICE_IBitmap* bmp, const Rect& panel, const PerformanceZone& zone); // S12/S15/S16 + keyTrack (Zone surface; the Sample face uses the r11 knob deck) + + // --- r11 Sample-face recomposition (FB1) --------------------------------------- + // The knob deck: the fenced task groups (AMP ENVELOPE / PITCH / PITCH ENV / VOICE / + // MASTER) drawn through the L1 kit — group fence + caption + compact caption toggles + + // radial knobs (param_slider's FA4 primitive) with label<->value swap on hover/drag. + void paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, const PerformanceZone& zone); + // The centered curve-popup sheet (wash + title + close + full-size curve editor). + void paintCurvePopup(LICE_IBitmap* bmp, int w, int h); // Trace the S-VIEW-3 amp-envelope overlay + its draggable node handles over `waveArea` for // `zone`'s play params, at the sample's wall-clock duration. Shared by the Sample hero band. void paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, const PerformanceZone& zone, @@ -170,6 +190,11 @@ private: void onMouseDown(int x, int y); void onMouseMove(int x, int y); void onMouseUp(int x, int y); + // r11: right-click — the curve popup's PRIMARY node-delete affordance (issue 3c). Only + // acts while the popup is open; a right-click on a popup curve node deletes it through + // the same commit path as Alt-click (deletePoint's endpoint guard makes endpoint + // right-clicks a safe no-op). Everything else ignores right-clicks. + void onMouseRDown(int x, int y); // Route a click at (x,y) into the param control panel `panel` editing map_.zones[zoneIndex]: // a toggle segment commits immediately, a slider grab starts a live param-drag (kParamSlider), @@ -337,6 +362,37 @@ private: // needs a concrete zone to write. Returns -1 if selectedId_ is empty. int ensureSampleZone(); + // --- r11 knob-deck plumbing (FB1) --------------------------------------------- + // + // The deck is the r11 replacement for the Sample face's slider control strip: the pure + // knob_deck module lays out the fenced groups, param_slider's FA4 primitive owns the + // value<->needle map, and these members own the control-id <-> value binding (the same + // division of labor paintControls/applyControl use for the Zone surface's sliders). + + // The deck group descriptors for the current mode: AMP ENVELOPE (Gate: A/H/D/S/R; + // Trigger: Fade In / Length % / Fade Out + two RESERVED blanks so a mode flip never + // reflows the neighbours) / PITCH (Key Track) / PITCH ENV (P.Attack/P.Decay/P.Depth) / + // VOICE (Voices knob + Poly|Mono caption toggle + Retrig|Legato row toggle) / MASTER + // (the FB1 post-mixer Gain knob). + std::vector deckGroupDescs(const ZonePlaySeconds& play) const; + + // The normalized [0,1] value a deck knob shows for `zone` — zone params route through + // controlValue/keyTrack; the processor-side ids (voice count, master gain, and the + // cluster's preview velocity via the -2 sentinel) read the processor's live value, so + // the knob and its storage are two views on one model (re-read each paint). + double deckControlNorm(int id, const PerformanceZone& zone) const; + + // Apply a deck-knob value: zone params write map_.zones[zoneIndex] (live-drag semantics, + // commit on release); processor params (voice count / master gain / preview velocity) + // write through the processor setters immediately (transient — no map edit, no reload). + // zoneIndex is ignored for processor-side ids. + void applyDeckKnob(int zoneIndex, int id, double norm); + + // The knob's live value label (shown in place of the name label during hover/drag): + // seconds ("0.123s"), percents ("85%"), source frames ("8820f"), signed semitones + // ("+3.5st"), a voice count ("16"), or the master-gain dB ("-inf"/"+2.4dB"). + std::string deckValueLabel(int id, const PerformanceZone& zone) const; + ReaSamplerProcessor* processor_ = nullptr; // --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) --- @@ -444,6 +500,15 @@ private: Rect dragCurveRect_{}; int dragCurveZone_ = -1; + // r11 deck-knob drag (FB1): the control's normalized value AT GRAB — knobDragValue maps + // the vertical pixel delta from this anchor, so a grab never jumps the value (FA4). + double dragKnobStartValue_ = 0.0; + + // r11 curve popup (FB1): open flag — editor-local, never persisted. The popup edits the + // picked capture's one-zone site (effectiveSampleZone / ensureSampleZone), re-resolved + // each paint so a sync-tick refresh mid-open stays coherent. + bool curvePopupOpen_ = false; + // --- 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/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index c81832d..aae1407 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -22,6 +22,7 @@ #include "bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision #include "capture_paths.h" // resolveBankFile (shared M4 path resolution) #include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey (shared wire contract) +#include "master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp) #include "reasampler_editor.h" #include "reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there) #include "sample_map.h" // selectSample, resolvePerformance, buildZonedKeymap, state (de)ser @@ -224,6 +225,10 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { voiceMode_ = cs.voiceMode; monoTrigger_ = cs.monoTrigger; } + // FB1: restore the post-mixer master gain (v8; older blobs lift to unity in + // deserializeComponentState — pre-FB1 output). One atomic store; the audio thread picks + // it up at the next block start. + setMasterGainLinear(cs.masterGainLinear); // Rebuild from the restored state (off-thread — setState is a load-time call). reloadFromBank(); return kResultOk; @@ -252,6 +257,7 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { state_out.voiceMode = voiceMode_; state_out.monoTrigger = monoTrigger_; } + state_out.masterGainLinear = masterGainLinear(); // FB1: persist the post-mixer gain (v8) const std::vector bytes = serializeComponentState(state_out); if (!bytes.empty()) { const tresult wr = state->write(const_cast(bytes.data()), @@ -351,6 +357,16 @@ void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) { rebuildVoiceEngine(); } +void ReaSamplerProcessor::setMasterGainLinear(double linear) { + // Clamp to the control's legal span (the master_gain taper: 0 = -inf/silence, cap = + // +24 dB). One relaxed atomic store — the audio thread reads it at the next block start; + // no rebuild, no lock (a post-sum output trim is not a keymap fact). + if (!(linear >= 0.0)) linear = 0.0; // also catches NaN + const double maxLin = masterGainMaxLinear(); + if (linear > maxLin) linear = maxLin; + masterGain_.store(static_cast(linear), std::memory_order_relaxed); +} + void ReaSamplerProcessor::previewNoteOn(int note) { if (note < 0) note = 0; if (note > 127) note = 127; @@ -877,6 +893,16 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { drain->engine.render(ch0, ch1, static_cast(frames)); drain->preview.render(ch0, ch1, static_cast(frames)); } + // FB1 post-mixer master gain: ONE relaxed load per block, applied AFTER the voice sum + // (engine + drain + preview) and BEFORE the extra-channel mirror + peak, so the mirror + // and the level indicator both see the actual output. A cheap multiply — no per-voice + // cost, no alloc, no lock (RT discipline). + { + const float g = masterGain_.load(std::memory_order_relaxed); + if (g != 1.f) { + for (int32 i = 0; i < frames; ++i) { ch0[i] *= g; ch1[i] *= g; } + } + } // Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2). for (int32 ch = 2; ch < out.numChannels; ++ch) { if (float* buf = out.channelBuffers32[ch]) { @@ -904,6 +930,14 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { drain->engine.render(ch0, static_cast(frames)); drain->preview.render(ch0, static_cast(frames)); } + // FB1 post-mixer master gain (mono path) — same contract as the stereo branch above: + // post-sum, pre-peak/replicate, one relaxed load, RT-safe. + { + const float g = masterGain_.load(std::memory_order_relaxed); + if (g != 1.f) { + for (int32 i = 0; i < frames; ++i) ch0[i] *= g; + } + } float peak = 0.f; for (int32 i = 0; i < frames; ++i) { const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i]; diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index c228dc3..858e9b0 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -210,6 +210,17 @@ public: MonoTrigger monoTrigger(); void setMonoTrigger(MonoTrigger trigger); + // --- FB1 post-mixer master gain (per-instance, persisted in component state v8) --------- + // LINEAR gain in [0, masterGainMaxLinear()] (0.0 = -inf/true silence, 1.0 = unity, cap = + // +24 dB; the pure master_gain module owns the dB knob taper). Held in an atomic so the + // audio thread applies it with ONE relaxed load per block as a post-sum multiply over the + // rendered output (engine + drain + preview) — no lock, no rebuild, no per-voice cost. + // Written by the editor's Gain knob (UI thread) and setState; read by getState + process(). + double masterGainLinear() const { + return static_cast(masterGain_.load(std::memory_order_relaxed)); + } + void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()] + // Fire a one-shot PREVIEW note-on / note-off through the live instrument's PREVIEW CARD // (S-VIEW-4; Phase S isolation) — a dedicated single voice structurally OUTSIDE the MIDI // pool, so a full pool never drops a preview and a preview never steals a playing voice. @@ -355,6 +366,11 @@ private: VoiceMode voiceMode_ = VoiceMode::Poly; MonoTrigger monoTrigger_ = MonoTrigger::Retrigger; + // FB1 post-mixer master gain (LINEAR; persisted in component state v8). A lock-free + // atomic — the ONE voice-param the audio thread reads directly (a single relaxed load + // per block, applied as a post-sum multiply). Default unity = pre-FB1 output. + std::atomic masterGain_{1.0f}; + // --- S-VIEW-4 preview-trigger mailbox (off-thread -> audio thread, lock-free) --------- // The editor's preview-trigger button posts a note-on/off request from the UI thread; process() // drains it at block start and drives the live instrument's PREVIEW CARD (Phase S — never the diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index dbcc2b5..882fb3f 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -5,9 +5,12 @@ #include // std::min #include // assert +#include // std::isfinite (v8 master-gain validation) #include // std::memcpy #include // std::move +#include "master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap + namespace reasampler { namespace { @@ -615,6 +618,17 @@ std::vector serializeComponentState(const ComponentState& state) { out.push_back(static_cast(vc)); out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0); out.push_back(state.monoTrigger == MonoTrigger::Legato ? 1 : 0); + // v8 envelope addition (FB1 master gain): the post-mixer LINEAR gain as an IEEE-754 double + // (bit-cast to u64 LE), following the voice bytes so a v7 blob is a strict prefix up to + // here (see the v7 lift). The WRITER never emits an out-of-range value: non-finite or + // negative falls back to unity; above the +24 dB cap clamps to the cap. + { + double g = state.masterGainLinear; + const double maxLin = vst::masterGainMaxLinear(); + if (!std::isfinite(g) || g < 0.0) g = 1.0; + if (g > maxLin) g = maxLin; + putU64le(out, doubleToBits(g)); + } // Length-prefixed selection id (it precedes the zones payload, so it MUST be framed — // unlike the v1 selection blob where the id ran to end-of-stream). putU32le(out, static_cast(state.selectionId.size())); @@ -691,11 +705,12 @@ ComponentState deserializeComponentState(const std::vector& bytes, return out; // previewVelocity stays at the mid default (pre-S-VIEW-4) } if (version != kComponentStateVersion && + version != kSelectionZonesModeMarkerVelVoiceV7Version && version != kSelectionZonesModeMarkerVelV6Version) { return out; // unknown -> empty } - // v6/v7 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker, + // v6/v7/v8 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker, // then the 1-byte preview velocity, precede the v3 body. A non-{0,1} mode byte is treated // as mono (conservative default) rather than rejected — a corrupt mode never silences the // instance. @@ -711,9 +726,9 @@ ComponentState deserializeComponentState(const std::vector& bytes, out.previewVelocity = (previewVel >= 1 && previewVel <= 127) ? previewVel : kPreviewVelocityDefault; - // v7 (Phase S): the three voice-system bytes. A v6 blob (pre-Phase-S) skips them — the + // v7+ (Phase S): the three voice-system bytes. A v6 blob (pre-Phase-S) skips them — the // construction defaults {16, Poly, Retrigger} hold, reproducing pre-Phase-S behavior. - if (version == kComponentStateVersion) { + if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) { const std::uint8_t vc = r.u8(); const std::uint8_t vm = r.u8(); const std::uint8_t mt = r.u8(); @@ -726,6 +741,17 @@ ComponentState deserializeComponentState(const std::vector& bytes, out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly; out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger; } + // v8 (FB1): the master-gain LINEAR double. A v7 blob (pre-FB1) skips it — the construction + // default (unity) holds, reproducing pre-FB1 output exactly. A non-finite, negative, or + // above-cap value (a corrupt blob) falls back to unity rather than silencing/blasting. + if (version == kComponentStateVersion) { + const double g = bitsToDouble(asU64(r.i64())); + if (!r.ok) return out; // truncated inside the gain double -> empty (unity holds) + out.masterGainLinear = + (std::isfinite(g) && g >= 0.0 && g <= vst::masterGainMaxLinear() * (1.0 + 1e-9)) + ? g + : 1.0; + } const std::uint32_t idLen = r.u32(); out.selectionId = r.str(idLen); if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index c08d419..9fd099d 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -447,22 +447,26 @@ 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 (envelope v7): 4-byte LE version tag (== 7), then a 1-byte channel-mode field (0 = mono, +// Format (envelope v8): 4-byte LE version tag (== 8), 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 // 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system // bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono -// trigger (0 = Retrigger, 1 = Legato), 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 three voice bytes are the ONLY envelope-v7 addition -// over envelope-v6 — the envelope grew fields, the zones payload is untouched (a PARALLEL track -// owns zone-record extension under its own versioning; the two version numbers are independent -// axes — do NOT bump the zones-payload version for an envelope field). An out-of-range voice -// byte (a corrupt blob) falls back to the field's default rather than silencing the instance -// (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to channelMode = -// MONO, lastConsumedAssignGeneration = 0, previewVelocity = kPreviewVelocityDefault, and the -// Phase-S voice defaults {16 voices, Poly, Retrigger} — which reproduce pre-Phase-S behavior -// exactly — preserving current behavior for already-saved instances): -// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones} direct. +// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754 +// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), 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 master-gain double is the ONLY envelope-v8 addition over v7 — 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 — do NOT bump the zones-payload +// version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range +// master-gain double (a corrupt blob) falls back to the field's default rather than silencing +// the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to +// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity = +// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, and unity +// master gain — which reproduce pre-v8 behavior exactly — preserving current behavior for +// already-saved instances): +// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones} direct. +// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain). // * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults). // * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity). // * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker). @@ -497,9 +501,20 @@ struct ComponentState { int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack) MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato + // FB1 (Wave B) post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; + // up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output + // trim applied by process() AFTER the voice sum (engine + drain + preview) — never per + // voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically, + // so an older blob lifting to 1.0 plays exactly as it did. + double masterGainLinear = 1.0; }; -inline constexpr std::uint32_t kComponentStateVersion = 7; +inline constexpr std::uint32_t kComponentStateVersion = 8; + +// The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker + +// preview velocity + voice system, no master gain). Retained so deserializeComponentState can +// lift a v7 blob to unity master gain. +inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7; // The pre-Phase-S combined-state version (selection + zones + channel mode + consumed marker + // preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a diff --git a/tests/test_curve_popup.cpp b/tests/test_curve_popup.cpp new file mode 100644 index 0000000..9e6c5c2 --- /dev/null +++ b/tests/test_curve_popup.cpp @@ -0,0 +1,95 @@ +// Standalone tests for reasampler::vst::curve_popup — no VST3, no REAPER, no framework. Same +// fast assert loop as the sibling pure tests. Assert the r11 popup-sheet geometry at the size +// clamps (the spec's width clamp(60%, 360..520) / height clamp(55%, 260..380)), the centering, +// the title-row/close-button placement, the curve-box remainder, and the outside-sheet +// dismissal test. + +#include "../src/vst/curve_popup.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 testDefaultWindowMidClamp() { + // 840x620: 60% = 504 (inside 360..520), 55% = 341 (inside 260..380). + const CurvePopupLayout pl = computeCurvePopup(840, 620); + CHECK(pl.sheet.width() == 504); + CHECK(pl.sheet.height() == 341); + // Centered (within the integer-division pixel). + CHECK(pl.sheet.left == (840 - 504) / 2); + CHECK(pl.sheet.top == (620 - 341) / 2); +} + +static void testMinClamp() { + // The 560x460 constraint floor: 60% = 336 -> clamps UP to 360; 55% = 253 -> up to 260. + const CurvePopupLayout pl = computeCurvePopup(560, 460); + CHECK(pl.sheet.width() == kCurvePopupMinW); + CHECK(pl.sheet.height() == kCurvePopupMinH); + CHECK(pl.sheet.left >= 0 && pl.sheet.right <= 560); + CHECK(pl.sheet.top >= 0 && pl.sheet.bottom <= 460); +} + +static void testMaxClamp() { + // A large window: 60% of 1600 = 960 -> clamps DOWN to 520; 55% of 900 = 495 -> down to 380. + const CurvePopupLayout pl = computeCurvePopup(1600, 900); + CHECK(pl.sheet.width() == kCurvePopupMaxW); + CHECK(pl.sheet.height() == kCurvePopupMaxH); +} + +static void testDegenerateWindowNeverOverhangs() { + // A window smaller than the min clamp: the sheet caps at the window dimension (defensive — + // below checkSizeConstraint, but geometry must stay sane). + const CurvePopupLayout pl = computeCurvePopup(300, 200); + CHECK(pl.sheet.width() == 300); + CHECK(pl.sheet.height() == 200); + CHECK(pl.sheet.left == 0 && pl.sheet.top == 0); +} + +static void testTitleRowAndCurveBox() { + const CurvePopupLayout pl = computeCurvePopup(840, 620); + // Close: 18x18, right-anchored inside the title row. + CHECK(pl.close.width() == kCurvePopupCloseSize && pl.close.height() == kCurvePopupCloseSize); + CHECK(pl.close.right == pl.sheet.right - kCurvePopupPad); + CHECK(pl.close.top >= pl.sheet.top); + CHECK(pl.close.bottom <= pl.sheet.top + kCurvePopupTitleH); + // Title text: left of the close button, in the title row. + CHECK(pl.title.left == pl.sheet.left + kCurvePopupPad); + CHECK(pl.title.right <= pl.close.left); + // Curve box: fills the remainder below the title row, inside the sheet margins. + CHECK(pl.curveBox.top >= pl.sheet.top + kCurvePopupTitleH); + CHECK(pl.curveBox.left == pl.sheet.left + kCurvePopupPad); + CHECK(pl.curveBox.right == pl.sheet.right - kCurvePopupPad); + CHECK(pl.curveBox.bottom == pl.sheet.bottom - kCurvePopupPad); + CHECK(pl.curveBox.width() > 0 && pl.curveBox.height() > 0); +} + +static void testOutsideSheetDismissTest() { + const CurvePopupLayout pl = computeCurvePopup(840, 620); + // On the wash: outside. + CHECK(popupOutsideSheet(pl, 0, 0)); + CHECK(popupOutsideSheet(pl, pl.sheet.left - 1, pl.sheet.top + 10)); + CHECK(popupOutsideSheet(pl, pl.sheet.right, pl.sheet.top + 10)); // half-open right edge + // On the sheet (title row, curve box, padding): inside. + CHECK(!popupOutsideSheet(pl, pl.sheet.left, pl.sheet.top)); + CHECK(!popupOutsideSheet(pl, pl.curveBox.left + 5, pl.curveBox.top + 5)); + CHECK(!popupOutsideSheet(pl, pl.sheet.right - 1, pl.sheet.bottom - 1)); +} + +int main() { + testDefaultWindowMidClamp(); + testMinClamp(); + testMaxClamp(); + testDegenerateWindowNeverOverhangs(); + testTitleRowAndCurveBox(); + testOutsideSheetDismissTest(); + if (g_fail) { + std::printf("%d FAILURE(S)\n", g_fail); + return 1; + } + std::printf("curve_popup tests passed\n"); + return 0; +} diff --git a/tests/test_knob_deck.cpp b/tests/test_knob_deck.cpp new file mode 100644 index 0000000..695c6fa --- /dev/null +++ b/tests/test_knob_deck.cpp @@ -0,0 +1,199 @@ +// Standalone tests for reasampler::vst::knob_deck — no VST3, no REAPER, no framework. Same fast +// assert loop as the sibling pure tests. Assert the r11 deck layout HARD: +// +// * group width — caption row vs knob row max + padding; row-toggle and caption-toggle widths. +// * layout — caption toggle right-anchored IN the caption row; cells fixed 48x58 left-to-right +// inside the box; knob square centered; label band beneath; row toggle after the cells. +// * wrap — deterministic whole-group wrap at a narrowing width (the r11 "PITCH ENV onto row +// two at the 560 floor" behavior); the first group of a row always places; deckHeight +// consistency with deckRowCount. +// * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, blank (-1) cells +// and fence padding miss, outside-deck miss. + +#include "../src/vst/knob_deck.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) + +// A representative deck shaped like the shell's: AMP (5 cells + caption toggle), PITCH +// (1 cell + caption toggle), PITCH ENV (3 cells + caption toggle), VOICE (1 cell + caption +// toggle + row toggle), MASTER (1 cell, no toggle). +static std::vector shellLikeDeck() { + std::vector g; + g.push_back({0, 78, {100, 44}, {1, 2, 3, 4, 5}, {}}); + g.push_back({1, 38, {101, 48}, {6}, {}}); + g.push_back({2, 58, {102, 32}, {7, 8, 9}, {}}); + g.push_back({3, 38, {103, 40}, {10}, {104, 44}}); + g.push_back({4, 46, {}, {11}, {}}); + return g; +} + +static void testGroupWidth() { + // Knob row dominates: 5 cells (240) > caption row (78 + 4 + 88 = 170) -> 240 + 2*6. + DeckGroupDesc amp{0, 78, {100, 44}, {1, 2, 3, 4, 5}, {}}; + CHECK(deckGroupWidth(amp) == 5 * kDeckCellW + 2 * kDeckGroupPadX); + // Caption row dominates: 38 + 4 + 96 = 138 > 48 -> 138 + 12. + DeckGroupDesc pitch{1, 38, {101, 48}, {6}, {}}; + CHECK(deckGroupWidth(pitch) == 38 + kDeckToggleGap + 2 * 48 + 2 * kDeckGroupPadX); + // Row toggle counts into the knob row: 48 + 4 + 88 = 140 > caption 38+4+80=122. + DeckGroupDesc voice{3, 38, {103, 40}, {10}, {104, 44}}; + CHECK(deckGroupWidth(voice) == + kDeckCellW + kDeckToggleGap + 2 * 44 + 2 * kDeckGroupPadX); + // No toggles: max(caption, cells) + padding. + DeckGroupDesc master{4, 46, {}, {11}, {}}; + CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX); +} + +static void testShellDeckFitsOneRowAtDefaultWidth() { + // The r11 default window is 840 with kPad=8 margins -> 824 available. The five shell + // groups must fit ONE deck row there (the elastic hero keeps ~430px — the layout spec's + // premise). Locks the constants against accidental growth. + const auto deck = shellLikeDeck(); + int total = 0; + for (const auto& g : deck) total += deckGroupWidth(g); + total += (static_cast(deck.size()) - 1) * kDeckGroupGap; + CHECK(total <= 824); + CHECK(deckRowCount(deck, 824) == 1); + CHECK(deckHeight(deck, 824) == kDeckGroupH); +} + +static void testWrapAtNarrowWidthIsDeterministic() { + // At the 560x460 checkSizeConstraint floor (544 available) the deck wraps to TWO rows, + // whole trailing groups only. + const auto deck = shellLikeDeck(); + CHECK(deckRowCount(deck, 544) == 2); + CHECK(deckHeight(deck, 544) == 2 * kDeckGroupH + kDeckRowGap); + const DeckLayout dl = layoutDeck(deck, 8, 100, 544); + CHECK(dl.rowCount == 2); + CHECK(dl.height == deckHeight(deck, 544)); + CHECK(dl.groups.size() == 5); + // Row membership: groups on row 1 share the first top; the wrapped groups sit one row + // pitch lower and restart at the left margin. + const int row0Top = dl.groups[0].box.top; + const int row1Top = row0Top + kDeckGroupH + kDeckRowGap; + CHECK(dl.groups[0].box.top == row0Top); + CHECK(dl.groups[1].box.top == row0Top); + bool sawWrap = false; + for (std::size_t i = 1; i < dl.groups.size(); ++i) { + if (dl.groups[i].box.top == row1Top && dl.groups[i - 1].box.top == row0Top) { + CHECK(dl.groups[i].box.left == 8); // wrapped row restarts at the left edge + sawWrap = true; + } + } + CHECK(sawWrap); + // Every box stays within the available width (no group straddles the right edge). + for (const auto& g : dl.groups) CHECK(g.box.right <= 8 + 544); +} + +static void testFirstGroupAlwaysPlaces() { + // A group wider than the row still places (degenerate width) — exactly one row per group. + const auto deck = shellLikeDeck(); + CHECK(deckRowCount(deck, 100) == 5); + CHECK(deckHeight(deck, 100) == 5 * kDeckGroupH + 4 * kDeckRowGap); +} + +static void testGroupInnerGeometry() { + const auto deck = shellLikeDeck(); + const DeckLayout dl = layoutDeck(deck, 8, 50, 824); + const DeckGroupLayout& amp = dl.groups[0]; + // Caption row at the top padding; caption toggle right-anchored inside the box. + CHECK(amp.caption.top == amp.box.top + kDeckGroupPadY); + CHECK(amp.captionToggle.id == 100); + CHECK(amp.captionToggle.seg1.right == amp.box.right - kDeckGroupPadX); + CHECK(amp.captionToggle.seg0.right == amp.captionToggle.seg1.left); + CHECK(amp.captionToggle.seg0.width() == 44 && amp.captionToggle.seg1.width() == 44); + CHECK(amp.captionToggle.seg0.height() == kDeckToggleH); + // The caption text rect stops before the toggle. + CHECK(amp.caption.right <= amp.captionToggle.seg0.left); + // Cells: five, fixed size, abutting, inside the box, below the caption row. + CHECK(static_cast(amp.cells.size()) == 5); + for (std::size_t i = 0; i < amp.cells.size(); ++i) { + const DeckCellLayout& c = amp.cells[i]; + CHECK(c.cell.width() == kDeckCellW && c.cell.height() == kDeckCellH); + CHECK(c.cell.top == amp.box.top + kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap); + if (i > 0) CHECK(c.cell.left == amp.cells[i - 1].cell.right); + // Knob square centered horizontally, label band beneath it, both inside the cell. + CHECK(c.knob.width() == kDeckKnobSize && c.knob.height() == kDeckKnobSize); + CHECK(c.knob.left - c.cell.left == c.cell.right - c.knob.right); + CHECK(c.label.top >= c.knob.bottom); + CHECK(c.label.bottom <= c.cell.bottom); + } + // VOICE group's row toggle sits after its cell, vertically centered in the cell row. + const DeckGroupLayout& voice = dl.groups[3]; + CHECK(voice.rowToggle.id == 104); + CHECK(voice.rowToggle.seg0.left == voice.cells[0].cell.right + kDeckToggleGap); + CHECK(voice.rowToggle.seg0.height() == kDeckToggleH); + CHECK(voice.rowToggle.seg0.top > voice.cells[0].cell.top); + // MASTER has no toggles. + CHECK(dl.groups[4].captionToggle.id == -1); + CHECK(dl.groups[4].rowToggle.id == -1); +} + +static void testHitTest() { + const auto deck = shellLikeDeck(); + const DeckLayout dl = layoutDeck(deck, 8, 50, 824); + const DeckGroupLayout& amp = dl.groups[0]; + + // Knob hit: anywhere in the cell (including the label band) resolves to the cell id. + const DeckCellLayout& c0 = amp.cells[0]; + DeckHit h = hitTestDeck(dl, c0.cell.left + 1, c0.cell.top + 1); + CHECK(h.kind == DeckHitKind::Knob && h.id == 1 && h.segment == -1); + h = hitTestDeck(dl, c0.label.left + 2, c0.label.top + 2); + CHECK(h.kind == DeckHitKind::Knob && h.id == 1); + + // Caption toggle segments 0/1 at their boundary: last px of seg0, first px of seg1. + h = hitTestDeck(dl, amp.captionToggle.seg0.right - 1, amp.captionToggle.seg0.top + 1); + CHECK(h.kind == DeckHitKind::CaptionToggle && h.id == 100 && h.segment == 0); + h = hitTestDeck(dl, amp.captionToggle.seg1.left, amp.captionToggle.seg1.top + 1); + CHECK(h.kind == DeckHitKind::CaptionToggle && h.id == 100 && h.segment == 1); + + // Row toggle. + const DeckGroupLayout& voice = dl.groups[3]; + h = hitTestDeck(dl, voice.rowToggle.seg1.left + 1, voice.rowToggle.seg1.top + 1); + CHECK(h.kind == DeckHitKind::RowToggle && h.id == 104 && h.segment == 1); + + // A blank cell (id -1) misses even though its rect exists. + std::vector trig; + trig.push_back({0, 78, {100, 44}, {20, 21, 22, -1, -1}, {}}); + const DeckLayout tl = layoutDeck(trig, 0, 0, 824); + const DeckCellLayout& blank = tl.groups[0].cells[4]; + CHECK(blank.id == -1); + h = hitTestDeck(tl, blank.cell.left + 5, blank.cell.top + 5); + CHECK(h.kind == DeckHitKind::None); + + // The fence padding inside the box misses; outside the deck misses. + h = hitTestDeck(dl, amp.box.left + 1, amp.box.bottom - 1); + CHECK(h.kind == DeckHitKind::None); + h = hitTestDeck(dl, -50, -50); + CHECK(h.kind == DeckHitKind::None); +} + +static void testEmptyDeck() { + const std::vector none; + CHECK(deckRowCount(none, 800) == 0); + CHECK(deckHeight(none, 800) == 0); + const DeckLayout dl = layoutDeck(none, 0, 0, 800); + CHECK(dl.groups.empty() && dl.rowCount == 0 && dl.height == 0); +} + +int main() { + testGroupWidth(); + testShellDeckFitsOneRowAtDefaultWidth(); + testWrapAtNarrowWidthIsDeterministic(); + testFirstGroupAlwaysPlaces(); + testGroupInnerGeometry(); + testHitTest(); + testEmptyDeck(); + if (g_fail) { + std::printf("%d FAILURE(S)\n", g_fail); + return 1; + } + std::printf("knob_deck tests passed\n"); + return 0; +} diff --git a/tests/test_master_gain.cpp b/tests/test_master_gain.cpp new file mode 100644 index 0000000..e13d4ac --- /dev/null +++ b/tests/test_master_gain.cpp @@ -0,0 +1,103 @@ +// Standalone tests for reasampler::vst::master_gain — no VST3, no REAPER, no framework. Same +// fast assert loop as the sibling pure tests. Assert the FB1 post-mixer gain taper HARD: +// +// * -inf bottom — norm 0 maps to -infinity dB and TRUE ZERO linear (silence, not an epsilon); +// linear <= 0 maps back to norm 0. +// * endpoints — norm 1 = +24 dB = masterGainMaxLinear() (~15.849); the finite floor at -60 dB. +// * unity — 0 dB round-trips exactly through norm<->dB<->linear (the knob's unity detent +// position 60/84 of the travel). +// * monotonicity — more norm never means less gain. +// * label — "-inf" at the bottom, signed one-decimal dB elsewhere. + +#include "../src/vst/master_gain.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) + +static bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b) <= eps; } + +static void testBottomIsTrueSilence() { + CHECK(std::isinf(masterGainDbFromNorm(0.0)) && masterGainDbFromNorm(0.0) < 0.0); + CHECK(masterGainLinearFromNorm(0.0) == 0.0); // exactly zero — the -inf contract + CHECK(masterGainNormFromLinear(0.0) == 0.0); + CHECK(masterGainNormFromLinear(-1.0) == 0.0); + CHECK(masterGainNormFromDb(-1e9) == 0.0); +} + +static void testEndpoints() { + CHECK(near(masterGainDbFromNorm(1.0), kMasterGainMaxDb)); + CHECK(near(masterGainLinearFromNorm(1.0), masterGainMaxLinear())); + CHECK(near(masterGainMaxLinear(), std::pow(10.0, 24.0 / 20.0))); + CHECK(near(masterGainNormFromDb(kMasterGainMaxDb), 1.0)); + CHECK(near(masterGainNormFromLinear(masterGainMaxLinear()), 1.0)); + // Out-of-range norms clamp. + CHECK(near(masterGainDbFromNorm(2.0), kMasterGainMaxDb)); + CHECK(masterGainLinearFromNorm(-0.5) == 0.0); +} + +static void testUnityRoundTrip() { + // 0 dB sits at norm -minDb/(maxDb-minDb) = 60/84 of the travel. + const double unityNorm = masterGainNormFromDb(0.0); + CHECK(near(unityNorm, 60.0 / 84.0, 1e-12)); + CHECK(near(masterGainDbFromNorm(unityNorm), 0.0, 1e-9)); + CHECK(near(masterGainLinearFromNorm(unityNorm), 1.0, 1e-9)); + CHECK(near(masterGainNormFromLinear(1.0), unityNorm, 1e-9)); +} + +static void testNormLinearRoundTripAcrossTravel() { + for (int i = 1; i <= 20; ++i) { + const double norm = i / 20.0; + const double lin = masterGainLinearFromNorm(norm); + CHECK(lin > 0.0); + CHECK(near(masterGainNormFromLinear(lin), norm, 1e-9)); + } +} + +static void testMonotonic() { + double prev = -1.0; + for (int i = 0; i <= 100; ++i) { + const double lin = masterGainLinearFromNorm(i / 100.0); + CHECK(lin > prev); // strictly increasing over the whole travel (0 at the bottom) + prev = lin; + } +} + +static void testNonFiniteLinearClamps() { + CHECK(masterGainNormFromLinear(std::nan("")) == 0.0); + CHECK(near(masterGainNormFromLinear(1e9), 1.0)); // above the cap clamps to 1 +} + +static void testLabels() { + char buf[24]; + formatMasterGainLabel(0.0, buf, sizeof(buf)); + CHECK(std::strcmp(buf, "-inf") == 0); + formatMasterGainLabel(1.0, buf, sizeof(buf)); + CHECK(std::strcmp(buf, "+24.0dB") == 0); + formatMasterGainLabel(masterGainNormFromDb(0.0), buf, sizeof(buf)); + CHECK(std::strcmp(buf, "+0.0dB") == 0); + formatMasterGainLabel(masterGainNormFromDb(-12.0), buf, sizeof(buf)); + CHECK(std::strcmp(buf, "-12.0dB") == 0); +} + +int main() { + testBottomIsTrueSilence(); + testEndpoints(); + testUnityRoundTrip(); + testNormLinearRoundTripAcrossTravel(); + testMonotonic(); + testNonFiniteLinearClamps(); + testLabels(); + if (g_fail) { + std::printf("%d FAILURE(S)\n", g_fail); + return 1; + } + std::printf("master_gain tests passed\n"); + return 0; +} diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index f5e2aa8..e190367 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -32,6 +32,7 @@ #include "../src/bank_book.h" #include "../src/bank_model.h" +#include "../src/vst/master_gain.h" // masterGainMaxLinear (the v8 master-gain wire cap) using namespace reasampler; @@ -1220,6 +1221,105 @@ static void testComponentStateV7TruncatedVoiceBytes() { CHECK(back.selectionId.empty() && back.map.zones.empty()); } +// --- v8 component state: the FB1 post-mixer master gain (linear double) ----------------------- + +static void testComponentStateMasterGainRoundTrip() { + // A non-default gain proves the bytes are read back, not defaulted; the envelope + // neighbours (voice bytes, velocity, selection, zones) ride alongside intact. + ComponentState s; + s.selectionId = "pick"; + s.previewVelocity = 99; + s.voiceCount = 5; + s.masterGainLinear = 0.25; // -12.04 dB + s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt)); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); + CHECK(std::fabs(back.masterGainLinear - 0.25) < 1e-12); // an exact double round-trip + CHECK(back.voiceCount == 5); + CHECK(back.previewVelocity == 99); + CHECK(back.selectionId == "pick"); + CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0"); +} + +static void testComponentStateMasterGainDefaultAndZeroRoundTrip() { + // Default unity round-trips (pre-FB1 output); the -inf bottom (TRUE zero) round-trips + // exactly — a user who pulled the gain to silence gets silence back after a save/load. + const ComponentState defBack = + deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0); + CHECK(defBack.masterGainLinear == 1.0); + ComponentState zero; + zero.masterGainLinear = 0.0; + const ComponentState zeroBack = + deserializeComponentState(serializeComponentState(zero), 44100.0); + CHECK(zeroBack.masterGainLinear == 0.0); +} + +static void testComponentStateMasterGainWriterClamps() { + // The WRITER never emits an out-of-range value: above the +24 dB cap clamps to the cap; + // a negative/non-finite value (a programming error upstream) falls back to unity. + ComponentState hi; + hi.masterGainLinear = 1000.0; + CHECK(std::fabs(deserializeComponentState(serializeComponentState(hi), 44100.0) + .masterGainLinear - + vst::masterGainMaxLinear()) < 1e-9); + ComponentState lo; + lo.masterGainLinear = -5.0; + CHECK(deserializeComponentState(serializeComponentState(lo), 44100.0).masterGainLinear == + 1.0); +} + +static void testComponentStateV7LiftsUnityMasterGain() { + // A GENUINE v7 blob (version tag 7: mode, marker, velocity, voice bytes, id, zones — NO + // master-gain double) lifts to unity, its other fields intact. Hand-built + // (serializeComponentState now emits v8, so it cannot make a v7 blob). Proves an + // already-saved pre-FB1 instance restores playing at exactly its old output level. + std::vector v7; + v7.push_back(7); v7.push_back(0); v7.push_back(0); v7.push_back(0); // version 7 + v7.push_back(1); // channel mode = stereo + for (int i = 0; i < 8; ++i) v7.push_back(0); // marker = 0 + v7.push_back(111); // preview velocity + v7.push_back(5); // voice count + v7.push_back(1); // voice mode = mono + v7.push_back(1); // trigger = legato + const std::string id = "saved"; + v7.push_back(static_cast(id.size())); v7.push_back(0); v7.push_back(0); v7.push_back(0); + v7.insert(v7.end(), id.begin(), id.end()); + v7.push_back(0); v7.push_back(0); v7.push_back(0); v7.push_back(0); // zone count 0 + const ComponentState back = deserializeComponentState(v7, 44100.0); + CHECK(back.masterGainLinear == 1.0); + CHECK(back.voiceCount == 5); + CHECK(back.voiceMode == VoiceMode::Mono); + CHECK(back.monoTrigger == MonoTrigger::Legato); + CHECK(back.previewVelocity == 111); + CHECK(back.channelMode == ChannelMode::Stereo); + CHECK(back.selectionId == "saved"); + CHECK(back.map.zones.empty()); +} + +static void testComponentStateV8CorruptMasterGainFallsBack() { + // A corrupt gain double (NaN) in a v8 blob falls back to unity (the previewVelocity + // corrupt-byte precedent) — never silences or blasts the instance. Build v8 by + // serializing, then vandalize the 8 gain bytes in place (offsets: 4 version + 1 mode + + // 8 marker + 1 velocity + 3 voice bytes = 17..24). + ComponentState s; + s.masterGainLinear = 0.5; + std::vector bytes = serializeComponentState(s); + for (int i = 0; i < 8; ++i) bytes[17 + i] = 0xFF; // 0xFFFF... = a negative NaN pattern + const ComponentState back = deserializeComponentState(bytes, 44100.0); + CHECK(back.masterGainLinear == 1.0); +} + +static void testComponentStateV8TruncatedMasterGain() { + // A v8 blob cut INSIDE the gain double -> empty, defaults holding (bounded read). + std::vector t{8, 0, 0, 0, 0}; // version 8, mode byte + for (int i = 0; i < 8; ++i) t.push_back(0); // full marker + t.push_back(64); // velocity byte + t.push_back(16); t.push_back(0); t.push_back(0); // the three voice bytes + t.push_back(0); t.push_back(0); t.push_back(0); // gain cut mid-double + const ComponentState back = deserializeComponentState(t, 44100.0); + CHECK(back.masterGainLinear == 1.0); + CHECK(back.selectionId.empty() && back.map.zones.empty()); +} + // --- MERGE COMPOSITION (S9 v5 marker envelope x S15/S16 v3 play-param payload) ---------------- // // The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and @@ -2030,6 +2130,12 @@ int main() { testComponentStateV6LiftsVoiceDefaults(); testComponentStateV7CorruptVoiceBytesFallBack(); testComponentStateV7TruncatedVoiceBytes(); + testComponentStateMasterGainRoundTrip(); + testComponentStateMasterGainDefaultAndZeroRoundTrip(); + testComponentStateMasterGainWriterClamps(); + testComponentStateV7LiftsUnityMasterGain(); + testComponentStateV8CorruptMasterGainFallsBack(); + testComponentStateV8TruncatedMasterGain(); testV5EnvelopeWithMarkerAndPlayParamsRoundTrip(); testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay(); testReconcileKeepsOnlySelectedFullRangeZone();