Q-W1 pt2: core/shell/app relocation + sub-namespaces; one concrete ui::Rect (LTRB fork retired); slot_map split from bank_book; BankIndex→BankModel; 59/59 green

This commit is contained in:
2026-07-28 20:48:56 -04:00
parent 67a41728f3
commit 847936f813
222 changed files with 2247 additions and 2079 deletions
+158
View File
@@ -0,0 +1,158 @@
// browser_scroll.cpp — see browser_scroll.h. PURE scroll + search geometry over the S10
// capture_browser. No host types; only the shared Rect + BrowserLayout.
#include "core/instrument/ui/browser_scroll.h"
#include <algorithm>
#include <cctype>
namespace reasampler::instrument::ui {
namespace {
// The minimum thumb height so a very long bank still yields a grabbable thumb.
constexpr int kMinThumbHeight = 20;
char asciiLower(char c) {
return static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
} // namespace
int scrollContentHeight(const BrowserLayout& layout, int cardCount) {
if (cardCount <= 0) return 0;
const int columns = (std::max)(1, layout.columns);
const int rows = (cardCount + columns - 1) / columns; // ceil
return rows * kBrowserCardHeight;
}
int scrollMaxOffset(const BrowserLayout& layout, int cardCount) {
const int content = scrollContentHeight(layout, cardCount);
const int gridH = (std::max)(0, layout.grid.height);
return (std::max)(0, content - gridH);
}
int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset) {
const int maxOff = scrollMaxOffset(layout, cardCount);
if (proposedOffset < 0) return 0;
if (proposedOffset > maxOff) return maxOff;
return proposedOffset;
}
VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset) {
VisibleRange vr;
if (cardCount <= 0) return vr;
const int columns = (std::max)(1, layout.columns);
const int gridH = (std::max)(0, layout.grid.height);
if (gridH <= 0 || kBrowserCardHeight <= 0) {
vr.first = 0;
vr.last = 0;
return vr;
}
if (offset < 0) offset = 0;
// First visible ROW: the topmost row whose bottom edge is below the offset. Floor so a row
// partially scrolled off the top still draws (its lower part is visible).
const int firstRow = offset / kBrowserCardHeight;
// Last visible ROW: the row containing the pixel (offset + gridH - 1), inclusive; +1 for
// the exclusive end. A row straddling the bottom edge still draws.
const int lastRow = (offset + gridH - 1) / kBrowserCardHeight + 1;
int first = firstRow * columns;
int last = lastRow * columns;
if (first > cardCount) first = cardCount;
if (last > cardCount) last = cardCount;
if (last < first) last = first;
vr.first = first;
vr.last = last;
return vr;
}
Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset) {
Rect r = cardCellRect(layout, index);
if (r.right() <= r.x && r.bottom() <= r.y) return r; // empty (negative index) stays empty
return Rect::ltrb(r.x, r.y - offset, r.right(), r.bottom() - offset);
}
Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset) {
const int content = scrollContentHeight(layout, cardCount);
const int gridH = (std::max)(0, layout.grid.height);
if (content <= gridH || gridH <= 0) return Rect{}; // fits -> no scrollbar
const int maxOff = content - gridH;
if (offset < 0) offset = 0;
if (offset > maxOff) offset = maxOff;
const int trackRight = layout.grid.right();
const int trackLeft = trackRight - kScrollbarWidth;
const int trackTop = layout.grid.y;
// Thumb height proportional to the visible fraction, floored at a grabbable minimum but
// never taller than the track.
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
thumbH = (std::max)(kMinThumbHeight, thumbH);
thumbH = (std::min)(thumbH, gridH);
// Thumb top proportional to the offset over the movable track span.
const int trackSpan = gridH - thumbH; // >= 0
int thumbTop = trackTop;
if (maxOff > 0 && trackSpan > 0) {
thumbTop = trackTop + static_cast<int>(
static_cast<long long>(offset) * trackSpan / maxOff);
}
return Rect::ltrb(trackLeft, thumbTop, trackRight, thumbTop + thumbH);
}
int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset,
int dyPixels) {
const int content = scrollContentHeight(layout, cardCount);
const int gridH = (std::max)(0, layout.grid.height);
if (content <= gridH || gridH <= 0) return clampScrollOffset(layout, cardCount, startOffset);
// Thumb height (same formula as scrollThumbRect) -> movable track span in thumb pixels.
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
thumbH = (std::max)(kMinThumbHeight, thumbH);
thumbH = (std::min)(thumbH, gridH);
const int trackSpan = gridH - thumbH;
if (trackSpan <= 0) return clampScrollOffset(layout, cardCount, startOffset);
const int maxOff = content - gridH;
// A 1px thumb move covers maxOff/trackSpan content px. Round to nearest for symmetry.
const long long deltaOffset =
(static_cast<long long>(dyPixels) * maxOff + (dyPixels >= 0 ? trackSpan / 2 : -trackSpan / 2)) /
trackSpan;
const long long proposed = static_cast<long long>(startOffset) + deltaOffset;
if (proposed < 0) return 0;
if (proposed > maxOff) return maxOff;
return static_cast<int>(proposed);
}
Rect searchBoxRect(int w) {
if (w <= 0) return Rect{};
return Rect::ltrb(0, 0, w, kSearchBoxHeight);
}
bool nameMatchesQuery(const std::string& name, const std::string& query) {
if (query.empty()) return true;
if (query.size() > name.size()) return false;
// Case-insensitive substring scan (ASCII fold). Small strings; a naive scan is fine.
for (std::size_t i = 0; i + query.size() <= name.size(); ++i) {
bool match = true;
for (std::size_t j = 0; j < query.size(); ++j) {
if (asciiLower(name[i + j]) != asciiLower(query[j])) {
match = false;
break;
}
}
if (match) return true;
}
return false;
}
std::vector<int> filterNameIndices(const std::vector<std::string>& names,
const std::string& query) {
std::vector<int> out;
out.reserve(names.size());
for (int i = 0; i < static_cast<int>(names.size()); ++i) {
if (nameMatchesQuery(names[static_cast<std::size_t>(i)], query))
out.push_back(i);
}
return out;
}
} // namespace reasampler::instrument::ui
+107
View File
@@ -0,0 +1,107 @@
// browser_scroll.h — PURE scroll + type-to-filter geometry LAYERED over the S10
// capture_browser. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of
// capture_browser / editor_geometry: the fiddly scroll-window + scrollbar-thumb + search-box
// arithmetic lives here, unit-tested outside the DAW, while the editor shell draws the
// clipped card window + the scrollbar + the search field and routes wheel/drag/keystrokes
// into these functions.
//
// WHY IT EXISTS (S12). capture_browser (S10) lays out EVERY card top-down and the shell
// clips at the browser bottom — a bank longer than the panel runs off with no way to reach
// it (the S12 gap). This module adds the two things S12 layers over that stable geometry:
// * SCROLL — a vertical pixel offset into the card grid, with the max-offset clamp, the
// visible-row window, a scrollbar thumb rect, and the thumb-drag<->offset mapping so a
// wheel tick or a thumb drag reaches every card; and
// * SEARCH — a name-substring filter (case-insensitive) that narrows the drawn cards,
// COMPOSING with capture_browser's bank filter (the shell applies the bank filter first,
// then this search narrows within it) + the search-box rect the shell draws the field in.
//
// It holds NO card data and draws nothing — it knows only the browser layout (from
// capture_browser), COUNTS, and the scroll OFFSET the shell owns as transient UI state. It
// reuses capture_browser's BrowserLayout + the shared Rect (one geometry idiom).
#pragma once
#include <string>
#include <vector>
#include "core/instrument/ui/capture_browser.h" // BrowserLayout, cardCellRect, kBrowserCardHeight, Rect
namespace reasampler::instrument::ui {
// The width (px) of the vertical scrollbar gutter at the right edge of the grid. The shell
// draws the track + thumb here and hit-tests thumb grabs against scrollThumbRect. Exposed so
// the shell and tests agree. When the content fits (no scroll needed) the scrollbar is
// suppressed (scrollThumbRect returns empty) and the shell may reclaim the gutter.
inline constexpr int kScrollbarWidth = 10;
// The height (px) of the type-to-filter search box the shell draws ABOVE the tab strip (a
// thin band spanning the browser width). Exposed so the shell reserves the band and tests
// agree. capture_browser's tab strip + grid sit BELOW this band (the shell offsets the
// BrowserLayout it feeds to capture_browser by kSearchBoxHeight).
inline constexpr int kSearchBoxHeight = 22;
// The total pixel HEIGHT the card grid needs to draw all `cardCount` cards at `layout`'s
// column count: the number of ROWS (ceil(cardCount / columns)) times the fixed cell height.
// Zero cards -> 0. Pure — the content extent the scroll offset ranges over.
int scrollContentHeight(const BrowserLayout& layout, int cardCount);
// The maximum scroll offset (px): content height minus the visible grid height, floored at 0.
// When the content fits within the grid this is 0 (nothing to scroll). Pure — the clamp
// ceiling for every offset the shell tracks.
int scrollMaxOffset(const BrowserLayout& layout, int cardCount);
// Clamp a proposed scroll offset into [0, scrollMaxOffset]. The shell clamps after every wheel
// tick / thumb drag so an over-scroll pins to an edge rather than showing past the last card
// or above the first. Pure.
int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset);
// The half-open range of card INDICES [first, last) at least partially visible in the grid at
// scroll `offset`. The shell draws only these cards (the S12 clip window) rather than every
// card. `offset` is assumed pre-clamped (the shell clamps on input); a first past the last row
// yields an empty range (first==last==cardCount). Pure.
struct VisibleRange {
int first = 0; // first card index drawn (inclusive)
int last = 0; // one past the last card index drawn (exclusive)
};
VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset);
// The cell rect of card `index` SHIFTED UP by the scroll offset, ready to draw (the shell
// still adds the browser sub-area origin). Equivalent to capture_browser::cardCellRect with
// the offset subtracted from top/bottom. Pure — the one place the offset applies to a card.
Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset);
// The vertical scrollbar THUMB rect within the grid's right-edge gutter, sized proportional to
// the visible fraction (grid height / content height) and positioned proportional to the
// scroll offset. Returns an EMPTY rect when the content fits (no scroll needed) — the shell
// suppresses the scrollbar then. A minimum thumb height keeps a tiny thumb grabbable on a very
// long bank. Pure — the geometry the shell draws + hit-tests the thumb grab against.
Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset);
// Map a thumb-drag to a scroll offset. Given the offset the thumb held at grab time
// (`startOffset`) and the vertical pixel delta since grab (`dyPixels`), returns the new
// (clamped) scroll offset: startOffset shifted by the delta scaled from thumb-track pixels to
// content pixels (a 1px thumb move covers content/track px of content). A degenerate track /
// fitting content pins to startOffset. Pure — the inverse of scrollThumbRect's position map.
int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, int dyPixels);
// The search-box rect: a full-width band of height kSearchBoxHeight at the TOP of the browser
// area (above where capture_browser's tab strip draws). `w` is the browser sub-area width;
// the shell adds its origin. A zero/negative width yields an empty rect. Pure.
Rect searchBoxRect(int w);
// True iff `name` contains `query` as a case-insensitive ASCII substring. An EMPTY query
// matches everything (the no-filter identity). Matching is ASCII case-folded (the display
// names are ASCII until the Phase L type kit lands, mirroring the editor's other ASCII-only
// text). Pure — the single match predicate the shell's search narrow is built from.
bool nameMatchesQuery(const std::string& name, const std::string& query);
// Narrow a list of display `names` to the INDICES whose name matches `query`, preserving
// order. An EMPTY query returns every index [0, names.size()) (the composition base so "bank
// filter, no search" == today's browser). Kept name-only (indices, not card structs) so this
// module stays free of the sample_map/bank_book chain — the shell owns the SampleChoice list
// and applies the bank filter FIRST, then feeds the surviving display names here (search
// narrows within the bank). Pure.
std::vector<int> filterNameIndices(const std::vector<std::string>& names,
const std::string& query);
} // namespace reasampler::instrument::ui
@@ -0,0 +1,96 @@
// capture_browser.cpp — see capture_browser.h. Pure math; no host types.
#include "core/instrument/ui/capture_browser.h"
#include <algorithm>
namespace reasampler::instrument::ui {
namespace {
// The left edge of tab i in a strip of the given x-origin and width divided into `count`
// equal segments (mirror of mode_switch::segmentEdge). Every boundary derives from the same
// formula, so consecutive tabs share an exact edge and the last tab reaches x+width exactly.
int tabEdge(int x, int width, int i, int count) {
return x + (i * width) / count;
}
} // namespace
BrowserLayout layoutBrowser(int w, int h) {
const int cw = std::max(0, w);
const int ch = std::max(0, h);
BrowserLayout out;
const int tabH = std::min(kBrowserTabHeight, ch);
out.tabStrip = Rect::ltrb(0, 0, cw, tabH);
out.grid = Rect::ltrb(0, tabH, cw, ch);
const int gridW = std::max(0, out.grid.width);
out.columns = std::max(1, gridW / kBrowserCardWidth);
return out;
}
Rect cardCellRect(const BrowserLayout& layout, int index) {
if (index < 0) return Rect{};
const int cols = std::max(1, layout.columns);
const int col = index % cols;
const int row = index / cols;
const int left = layout.grid.x + col * kBrowserCardWidth;
const int top = layout.grid.y + row * kBrowserCardHeight;
return Rect::ltrb(left, top, left + kBrowserCardWidth, top + kBrowserCardHeight);
}
Rect cardContentRect(const BrowserLayout& layout, int index) {
if (index < 0) return Rect{};
const Rect cell = cardCellRect(layout, index);
return Rect::ltrb(cell.x + kBrowserCardGutter, cell.y + kBrowserCardGutter,
cell.right() - kBrowserCardGutter, cell.bottom() - kBrowserCardGutter);
}
Rect cardThumbnailRect(const BrowserLayout& layout, int index) {
if (index < 0) return Rect{};
const Rect content = cardContentRect(layout, index);
const int thumbH = std::min(kBrowserThumbHeight, std::max(0, content.height));
return Rect::ltrb(content.x, content.y, content.right(), content.y + thumbH);
}
Rect cardLabelRect(const BrowserLayout& layout, int index) {
if (index < 0) return Rect{};
const Rect content = cardContentRect(layout, index);
const Rect thumb = cardThumbnailRect(layout, index);
return Rect::ltrb(content.x, thumb.bottom(), content.right(), content.bottom());
}
int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y) {
if (cardCount <= 0) return -1;
if (!contains(layout.grid, x, y)) return -1;
const int cols = std::max(1, layout.columns);
const int col = (x - layout.grid.x) / kBrowserCardWidth;
const int row = (y - layout.grid.y) / kBrowserCardHeight;
if (col < 0 || col >= cols) return -1; // past the last column (right dead-zone)
const int index = row * cols + col;
if (index < 0 || index >= cardCount) return -1;
// Only a hit inside the card CONTENT counts — a click in the inter-card gutter misses.
if (!contains(cardContentRect(layout, index), x, y)) return -1;
return index;
}
Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index) {
if (tabCount <= 0 || index < 0 || index >= tabCount) return Rect{};
const Rect& strip = layout.tabStrip;
const int left = tabEdge(strip.x, std::max(0, strip.width), index, tabCount);
const int right = tabEdge(strip.x, std::max(0, strip.width), index + 1, tabCount);
return Rect::ltrb(left, strip.y, right, strip.bottom());
}
int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y) {
if (tabCount <= 0) return -1;
if (!contains(layout.tabStrip, x, y)) return -1;
for (int i = 0; i < tabCount; ++i) {
if (contains(filterTabRect(layout, tabCount, i), x, y)) return i;
}
return -1;
}
} // namespace reasampler::instrument::ui
+92
View File
@@ -0,0 +1,92 @@
// capture_browser.h — PURE layout + hit-test for the S10 capture-first editor's default
// face: a scannable grid of capture CARDS with a bank-FILTER tab strip above it. NO VST3,
// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry /
// embed_strip / mode_switch: the fiddly card-grid + tab arithmetic lives here so it is
// unit-tested outside the DAW, while the editor shell draws each card's peak thumbnail +
// name + root/key badge and routes clicks into these functions.
//
// The browser replaces the old text item-list (the named anti-pattern). It lays out N
// cards in a fixed-cell grid that wraps across the browser width, and a horizontal tab
// strip of bank filters (one tab per bank_book bank + an "All" tab) above the grid. This
// module knows only COUNTS and RECTS — it draws nothing and holds no sample data; the
// shell owns the SampleChoice list, the peak envelopes, and the filter state, and asks this
// module only "where does card i draw" / "what did the user click".
//
// Scroll is NOT here (S12 layers it over this module). The browser lays out every card
// top-down; the shell clips at the browser's bottom until S12 adds a scroll offset. Keeping
// scroll out keeps this module the stable card/tab geometry S12 builds on.
//
// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom).
#pragma once
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
namespace reasampler::instrument::ui {
// Fixed browser metrics, exposed so the shell and tests agree. The card is sized to show a
// peak thumbnail with a name + badge line under it — scannable by eye, not a dense list.
inline constexpr int kBrowserTabHeight = 26; // the bank-filter tab strip band height
inline constexpr int kBrowserCardWidth = 132; // one card cell width (incl. gutter)
inline constexpr int kBrowserCardHeight = 84; // one card cell height (incl. gutter)
inline constexpr int kBrowserCardGutter = 8; // inset between the cell edge and the card
inline constexpr int kBrowserThumbHeight = 44; // the peak-thumbnail band inside a card
// The browser's regions, derived from the (w x h) area the shell allots it. Both clamp to
// the area so a degenerate (tiny/zero) size never yields an inverted rect.
struct BrowserLayout {
Rect tabStrip; // top: the bank-filter tabs
Rect grid; // below the tabs: where the capture cards tile
int columns = 1; // cards per row in `grid` (>= 1); derived from grid.width
};
// Divide a (w x h) browser area into its regions and compute the column count. Pure: same
// inputs -> same layout. The tab strip takes a fixed height at the top (clamped so it never
// exceeds the area); the grid takes the rest. columns = max(1, grid.width/cardWidth) so a
// browser narrower than one card still lays out a single column. A zero/negative size
// yields empty rects + columns==1.
BrowserLayout layoutBrowser(int w, int h);
// The cell rect of capture card `index` (0-based) in the grid, laid out left-to-right then
// top-to-bottom across `columns`. This is the full CELL (card + gutter); cardContentRect
// insets it to the drawable card. Rows past the visible grid are still computed (the shell
// clips at paint time). A negative index yields an empty rect. Pure.
Rect cardCellRect(const BrowserLayout& layout, int index);
// The drawable card rect inside a cell: the cell inset by kBrowserCardGutter on all sides.
// The shell fills this (background + border) and draws the thumbnail/name/badge inside it. Pure.
Rect cardContentRect(const BrowserLayout& layout, int index);
// The peak-thumbnail sub-rect at the top of a card's content: full card width, the top
// kBrowserThumbHeight (clamped to the card height). The shell draws the envelope here; the
// name + badge go in the remaining strip below. Pure.
Rect cardThumbnailRect(const BrowserLayout& layout, int index);
// The name/badge sub-rect below the thumbnail: the card content minus the thumbnail band.
// The shell draws the display name + root/key badge here. Pure.
Rect cardLabelRect(const BrowserLayout& layout, int index);
// The card a click at (x, y) lands on, given `cardCount` cards, or -1 for a click outside
// every card (in a gutter, past the last card, or on the tab strip). Only the card CONTENT
// rect counts as a hit — a click in the inter-card gutter is a miss. Pure.
int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y);
// --- Bank-filter tabs --------------------------------------------------------
//
// The tab strip divides tabStrip into `tabCount` equal segments (mirror of mode_switch):
// one tab per bank_book bank plus a leading "All" tab the shell prepends, so tabCount ==
// bankCount + 1 in practice. This module only divides the strip + hit-tests; the shell
// supplies the labels and tracks which tab is active. A tab click narrows the card list to
// that bank (the shell filters its SampleChoice list before laying out cards).
// The rect of tab `index` (0-based) when the strip is divided into `tabCount` equal
// segments. The last tab absorbs any width remainder so the tabs tile the whole strip with
// no gap (mirror of mode_switch's segment split). A negative index or tabCount<=0 yields an
// empty rect. Pure.
Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index);
// The tab a click at (x, y) lands on, given `tabCount` tabs, or -1 for a click outside the
// tab strip. Pure.
int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y);
} // namespace reasampler::instrument::ui
+41
View File
@@ -0,0 +1,41 @@
// curve_popup.cpp — see curve_popup.h. Pure arithmetic; no LICE/VST3/REAPER includes.
#include "core/instrument/ui/curve_popup.h"
#include <algorithm>
namespace reasampler::instrument::ui {
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::ltrb(left, top, left + sheetW, top + sheetH);
const int titleBottom = out.sheet.y + kCurvePopupTitleH;
const int closeTop = out.sheet.y + (kCurvePopupTitleH - kCurvePopupCloseSize) / 2;
out.close = Rect::ltrb(out.sheet.right() - kCurvePopupPad - kCurvePopupCloseSize, closeTop,
out.sheet.right() - kCurvePopupPad, closeTop + kCurvePopupCloseSize);
out.title = Rect::ltrb(out.sheet.x + kCurvePopupPad, out.sheet.y,
out.close.x - kCurvePopupPad, titleBottom);
out.curveBox = Rect::ltrb(out.sheet.x + 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::instrument::ui
+48
View File
@@ -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 "core/instrument/ui/editor_geometry.h" // Rect, contains
namespace reasampler::instrument::ui {
// 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::instrument::ui
+162
View File
@@ -0,0 +1,162 @@
// editor_geometry.cpp — see editor_geometry.h. Pure math; no host types.
#include "core/instrument/ui/editor_geometry.h"
#include <algorithm>
namespace reasampler::instrument::ui {
namespace {
// Spike editor layout constants. These are the editor's fixed metrics; the real
// editor (S4/S5) will parameterize as its content demands.
constexpr int kTitleBarHeight = 28;
constexpr int kButtonMargin = 10;
constexpr int kButtonWidth = 120;
constexpr int kButtonHeight = 24;
} // namespace
// contains() now lives with the shared ui::Rect (core/ui/rect.h) — same half-open
// semantics, re-exported through the header's using-declaration.
EditorLayout layoutEditor(int w, int h) {
// Clamp the surface to non-negative extents so a degenerate view can't produce
// inverted rects.
const int cw = std::max(0, w);
const int ch = std::max(0, h);
EditorLayout out;
// Title bar spans the top, clamped so it never exceeds the client height.
const int titleH = std::min(kTitleBarHeight, ch);
out.titleBar = Rect::ltrb(0, 0, cw, titleH);
// Canvas is everything below the title bar.
out.canvas = Rect::ltrb(0, titleH, cw, ch);
// Button sits at the top-left of the canvas, inset by a margin, and is clamped to
// fit inside the canvas so it never overhangs on a small view.
const int bx = out.canvas.x + kButtonMargin;
const int by = out.canvas.y + kButtonMargin;
const int bRight = std::min(bx + kButtonWidth, out.canvas.right());
const int bBottom = std::min(by + kButtonHeight, out.canvas.bottom());
out.button = Rect::ltrb(bx, by, std::max(bx, bRight), std::max(by, bBottom));
return out;
}
HitTarget hitTest(const EditorLayout& layout, int x, int y) {
if (contains(layout.button, x, y)) return HitTarget::kButton;
return HitTarget::kNone;
}
Rect sampleRowRect(const EditorLayout& layout, int index) {
if (index < 0) return Rect{};
const int top = layout.canvas.y + index * kSampleRowHeight;
return Rect::ltrb(layout.canvas.x, top, layout.canvas.right(), top + kSampleRowHeight);
}
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) {
if (rowCount <= 0) return -1;
// Must be within the canvas horizontally and at/below its top.
if (x < layout.canvas.x || x >= layout.canvas.right()) return -1;
if (y < layout.canvas.y) return -1;
// Clip at the canvas bottom: clicks in the canvas's dead-zone below the last
// visible row agree with sampleRowRect, which does not clamp rows to canvas.bottom().
if (y >= layout.canvas.bottom()) return -1;
const int index = (y - layout.canvas.y) / kSampleRowHeight;
if (index < 0 || index >= rowCount) return -1;
// Guard the bottom edge: a click below the last row's bottom is outside.
const Rect r = sampleRowRect(layout, index);
if (y >= r.bottom()) return -1;
return index;
}
// --- Keymap editor -----------------------------------------------------------
KeymapEditorLayout layoutKeymapEditor(int w, int h) {
KeymapEditorLayout out;
out.base = layoutEditor(w, h);
const Rect& canvas = out.base.canvas;
// Split the canvas vertically: the left column is the bank-sample list, the right
// column (1/kZonePanelFraction of the width) is the zone panel. Guard tiny widths so
// the split point never crosses the canvas edges.
const int canvasW = std::max(0, canvas.width);
const int splitW = canvasW / kZonePanelFraction; // width of the zone panel
const int splitX = std::max(canvas.x, canvas.right() - splitW);
out.sampleList = Rect::ltrb(canvas.x, canvas.y, splitX, canvas.bottom());
out.zonePanel = Rect::ltrb(splitX, canvas.y, canvas.right(), canvas.bottom());
// "Add Zone" button spans the top of the zone panel, clamped to its height.
const int addH = std::min(kAddZoneHeight, std::max(0, out.zonePanel.height));
out.addZoneButton =
Rect::ltrb(out.zonePanel.x, out.zonePanel.y, out.zonePanel.right(),
out.zonePanel.y + addH);
// Zone rows stack below the button.
out.zoneRowArea = Rect::ltrb(out.zonePanel.x, out.addZoneButton.bottom(),
out.zonePanel.right(), out.zonePanel.bottom());
return out;
}
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index) {
if (index < 0) return Rect{};
const int top = layout.sampleList.y + index * kSampleRowHeight;
return Rect::ltrb(layout.sampleList.x, top, layout.sampleList.right(),
top + kSampleRowHeight);
}
int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y) {
if (rowCount <= 0) return -1;
const Rect& list = layout.sampleList;
if (x < list.x || x >= list.right()) return -1;
if (y < list.y || y >= list.bottom()) return -1;
const int index = (y - list.y) / kSampleRowHeight;
if (index < 0 || index >= rowCount) return -1;
const Rect r = keymapSampleRowRect(layout, index);
if (y >= r.bottom()) return -1;
return index;
}
Rect zoneRowRect(const KeymapEditorLayout& layout, int index) {
if (index < 0) return Rect{};
const int top = layout.zoneRowArea.y + index * kZoneRowHeight;
return Rect::ltrb(layout.zoneRowArea.x, top, layout.zoneRowArea.right(),
top + kZoneRowHeight);
}
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y) {
if (zoneCount <= 0) return ZoneHit{};
const Rect& area = layout.zoneRowArea;
if (x < area.x || x >= area.right()) return ZoneHit{};
if (y < area.y || y >= area.bottom()) return ZoneHit{};
const int index = (y - area.y) / kZoneRowHeight;
if (index < 0 || index >= zoneCount) return ZoneHit{};
const Rect row = zoneRowRect(layout, index);
if (y >= row.bottom()) return ZoneHit{};
// Seven mini-buttons pinned to the right edge, right-to-left:
// delete, root+, root-, high+, high-, low+, low-
// Each is kZoneCtrlWidth wide. A click left of the leftmost is the label ("select").
// The fields laid out LEFT-TO-RIGHT in slot order 0..6.
const ZoneField fields[7] = {
ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown,
ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp,
ZoneField::kDelete,
};
const int slots = 7;
const int ctrlBlockLeft = row.right() - slots * kZoneCtrlWidth;
if (x < ctrlBlockLeft) return ZoneHit{index, ZoneField::kZoneNone}; // label -> select
const int slot = (x - ctrlBlockLeft) / kZoneCtrlWidth;
if (slot < 0 || slot >= slots) return ZoneHit{index, ZoneField::kZoneNone};
return ZoneHit{index, fields[slot]};
}
bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y) {
return contains(layout.addZoneButton, x, y);
}
} // namespace reasampler::instrument::ui
+142
View File
@@ -0,0 +1,142 @@
// editor_geometry.h — PURE view geometry + hit-test for the VST3 IPlugView LICE
// editor (Phase S1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary.
//
// The IPlugView shell (reasampler_editor.cpp) owns the window/bitmap/SWELL plumbing
// and is DAW-verified; this module holds the fiddly rectangle math and hit-testing so
// it can be unit-tested outside the DAW — the mirror of how bank_grid / mode_switch /
// tab_strip split their layout math out of the panel shell.
//
// The spike's editor is deliberately trivial (a title band + one clickable button),
// enough to PROVE the host->draw/hit-test event routing works. As the real editor
// (S4/S5) grows, its layout math accretes here, not in the shell.
#pragma once
#include "core/ui/rect.h"
namespace reasampler::instrument::ui {
// The shared pixel rectangle + containment test (Q-W1, T2-05 ≡ T4-21): the former
// LTRB Rect defined here is folded into the ONE concrete ui::Rect (XYWH storage,
// right()/bottom() accessors, Rect::ltrb() for edge-wise construction, same
// half-open convention). Aliased here so every instrument-ui call site keeps its
// established `Rect` / `contains` spelling.
using Rect = ::reasampler::ui::Rect;
using ::reasampler::ui::contains;
// The regions the spike editor draws, derived from the current view size. All are
// clamped to the client area so a degenerate (too-small) view never yields a region
// that spills outside the surface.
struct EditorLayout {
Rect titleBar; // top band: the plugin name + a live-state readout
Rect button; // a single clickable button (proves hit-test routing)
Rect canvas; // the remaining surface below the title bar
};
// Divide a (w x h) client area into the spike editor's regions. Pure: the same
// inputs always yield the same layout. Guards tiny sizes — every returned rect stays
// within [0,w] x [0,h], and the button never overhangs the canvas.
EditorLayout layoutEditor(int w, int h);
// The editor's hit-test targets. kNone means the point landed on inert surface.
enum class HitTarget {
kNone,
kButton,
};
// Classify a click at (x, y) against a layout. The button wins only when the point is
// inside the button rect; everything else (including the title bar and empty canvas)
// is kNone in the spike.
HitTarget hitTest(const EditorLayout& layout, int x, int y);
// --- Sample-selection list (S4 Tier-0 UI) -----------------------------------
//
// The Tier-0 editor lists the bank's samples as a vertical stack of fixed-height rows
// below the title bar; clicking a row selects that sample. This is the pure geometry:
// the row rectangles and the point->row hit-test, unit-tested outside the DAW while the
// shell draws the names and routes the click into the processor's reloadInstrument.
// The fixed row height (px) for one sample entry. Exposed so the shell and tests agree.
inline constexpr int kSampleRowHeight = 22;
// The rectangle for row `index` (0-based) of the sample list, laid out top-down inside
// the layout's canvas. Rows beyond what the canvas can show are still computed (the
// shell clips at paint time); a negative index yields an empty rect. Pure.
Rect sampleRowRect(const EditorLayout& layout, int index);
// The row index a click at (x, y) lands on, given `rowCount` rows, or -1 for a click
// outside the list (above the first row, past the last, or on the title bar). Pure.
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y);
// --- Keymap editor (S5 Tier-1 UI) -------------------------------------------
//
// The Tier-1 editor splits the canvas into a LEFT bank-sample list (the same rows as
// Tier 0, reused for the "sample to add / fallback pick") and a RIGHT zone panel listing
// the performance map's zones. An "Add Zone" button sits at the top of the zone panel;
// each zone row carries small nudge/delete controls so the user can set the range and
// root note without a text field (LICE has no native numeric entry). All rectangle math
// is here so the shell only draws + routes — the mirror of the sample-list split above.
// Fixed metrics for the zone panel, exposed so the shell and tests agree.
inline constexpr int kZoneRowHeight = 24;
inline constexpr int kZonePanelFraction = 2; // zone panel gets the RIGHT 1/2 of the canvas
inline constexpr int kZoneCtrlWidth = 20; // width of one nudge/delete mini-button
inline constexpr int kAddZoneHeight = 22; // the "Add Zone" button band height
// The keymap editor's regions, derived from the (w x h) client area. All clamp to the
// canvas so a degenerate view yields in-bounds rects.
struct KeymapEditorLayout {
EditorLayout base; // title bar + canvas (the sample list uses base.canvas.x half)
Rect sampleList; // LEFT column: the bank-sample rows (sampleRowRect is relative here)
Rect zonePanel; // RIGHT column: the "Add Zone" button + the zone rows
Rect addZoneButton; // top of the zone panel
Rect zoneRowArea; // below addZoneButton: where zone rows stack
};
KeymapEditorLayout layoutKeymapEditor(int w, int h);
// The rectangle for bank-sample row `index` inside the LEFT sample list column of a
// keymap layout. Same fixed height as the Tier-0 list; laid out top-down inside
// sampleList. Negative index -> empty. Pure.
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index);
// The bank-sample row a click lands on inside the left list, or -1 outside it. Pure.
int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y);
// The rectangle for zone row `index` inside the zone panel's zoneRowArea. Negative
// index -> empty. Pure.
Rect zoneRowRect(const KeymapEditorLayout& layout, int index);
// A zone row's interactive fields. The row is a horizontal strip: a label on the left,
// then seven fixed-width mini-buttons on the right (left-to-right: low-, low+, high-, high+,
// root-, root+, delete). kZoneNone means the click missed a control
// (e.g. on the label) — the shell may still treat that as "select this zone".
enum class ZoneField {
kZoneNone,
kLowDown,
kLowUp,
kHighDown,
kHighUp,
kRootDown,
kRootUp,
kDelete,
};
// The result of hit-testing a click against the zone rows: which zone row (or -1) and
// which field within it. A click on the "Add Zone" button is reported separately by
// addZoneHitTest — this covers only the zone rows.
struct ZoneHit {
int zoneIndex = -1;
ZoneField field = ZoneField::kZoneNone;
};
// Classify a click at (x, y) against `zoneCount` zone rows. Returns {-1, kZoneNone} for a
// click outside every zone row. Within a row, the seven mini-buttons occupy fixed-width
// slots on the right edge (left-to-right: low-, low+, high-, high+, root-, root+, delete);
// a click left of those slots is {index, kZoneNone} (the label area — "select"). Pure.
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y);
// True if (x, y) lands on the "Add Zone" button. Pure.
bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y);
} // namespace reasampler::instrument::ui
+86
View File
@@ -0,0 +1,86 @@
// embed_strip.cpp — see embed_strip.h. Pure math; no host types.
#include "core/instrument/ui/embed_strip.h"
#include <algorithm>
namespace reasampler::instrument::ui {
namespace {
// Clamp a MIDI note to [0, kEmbedKeyCount-1].
int clampNote(int n) {
if (n < 0) return 0;
if (n > kEmbedKeyCount - 1) return kEmbedKeyCount - 1;
return n;
}
// Map a key boundary in [0, kEmbedKeyCount] to an x pixel inside a band of the given
// left/width. keyEdge is a boundary (0..128), so keyEdge==128 maps to the band's right.
// Integer math, floored — a zone's left uses floor(low) and its right uses floor(high+1),
// which tiles adjacent zones without a seam.
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
if (keyEdge <= 0) return bandLeft;
if (keyEdge >= kEmbedKeyCount) return bandLeft + bandWidth;
return bandLeft + (keyEdge * bandWidth) / kEmbedKeyCount;
}
} // namespace
EmbedLayout layoutEmbed(int w, int h) {
const int cw = std::max(0, w);
const int ch = std::max(0, h);
EmbedLayout out;
// The level band takes a fixed height at the bottom, but never so much that the keymap
// above it falls below its minimum (or that the band exceeds the area). On a very short
// area the band yields to the keymap entirely.
int bandH = std::min(kEmbedLevelBandHeight, ch);
if (ch - bandH < kEmbedKeymapMinHeight) {
bandH = std::max(0, ch - kEmbedKeymapMinHeight);
}
const int keymapBottom = ch - bandH;
out.keymap = Rect::ltrb(0, 0, cw, keymapBottom);
out.levelBand = Rect::ltrb(0, keymapBottom, cw, ch);
return out;
}
Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote) {
const Rect& band = layout.keymap;
const int bandWidth = std::max(0, band.width);
int lo = clampNote(lowNote);
int hi = clampNote(highNote);
if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts
const int leftX = keyEdgeToX(band.x, bandWidth, lo);
const int rightX = keyEdgeToX(band.x, bandWidth, hi + 1);
return Rect::ltrb(leftX, band.y, std::max(leftX, rightX), band.bottom());
}
int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x,
int y) {
if (zoneCount <= 0 || zones == nullptr) return -1;
if (!contains(layout.keymap, x, y)) return -1;
// First covering zone in draw order wins (first-match, mirroring the core's resolve).
for (int i = 0; i < zoneCount; ++i) {
const Rect r = zoneSegmentRect(layout, zones[i].lowNote, zones[i].highNote);
if (contains(r, x, y)) return i;
}
return -1; // on the band but on an uncovered key
}
Rect levelFillRect(const EmbedLayout& layout, double level) {
const Rect& band = layout.levelBand;
if (band.width <= 0 || band.height <= 0) return Rect{};
double l = level;
if (l < 0.0) l = 0.0;
if (l > 1.0) l = 1.0;
const int fillW = static_cast<int>(l * band.width);
if (fillW <= 0) return Rect{};
return Rect::ltrb(band.x, band.y, band.x + fillW, band.bottom());
}
} // namespace reasampler::instrument::ui
+76
View File
@@ -0,0 +1,76 @@
// embed_strip.h — PURE layout + hit-test for the S6 embedded TCP/MCP strip. NO VST3,
// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry /
// mode_switch: the fiddly rectangle math for the compact inline keymap/level strip lives
// here so it is unit-tested outside the DAW, while the embed shell (reasampler_embed.cpp)
// marshals REAPER's embed messages (paint bitmap + mouse coords) into these functions.
//
// The strip is a single compact band REAPER draws inline in the track/mixer control panel
// (context TCP or MCP) via the Cockos embedded-UI surface. It shows:
// * the zone layout — each performance zone as a horizontal segment across the keyboard
// span (MIDI 0..127 mapped to the strip width), so the keymap reads at a glance; and
// * a thin level band at the bottom — a 0..1 activity indicator the shell fills.
// Interaction is zone SELECTION at most (S6 constraint: no new editing semantics) — a
// click maps to the zone whose key range covers that point, or -1.
//
// It reuses the same Rect + contains() as editor_geometry (the strip and the editor share
// one geometry idiom), so this header depends on editor_geometry.h rather than redefining
// a second rectangle type.
#pragma once
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
namespace reasampler::instrument::ui {
// The full MIDI key span the strip maps across its width. 128 keys (0..127); the strip's
// horizontal axis is this range, so a zone [lowNote, highNote] becomes a sub-rectangle.
inline constexpr int kEmbedKeyCount = 128;
// Fixed metrics for the strip, exposed so the shell and tests agree.
inline constexpr int kEmbedLevelBandHeight = 4; // the bottom activity band (px)
inline constexpr int kEmbedKeymapMinHeight = 6; // keymap area collapses no smaller
// One zone rendered on the strip: its inclusive MIDI key range. This is the minimal
// projection of a PerformanceZone the strip needs (it does not carry sample ids or PCM —
// the shell resolves labels; the strip only lays out ranges). lowNote/highNote are
// expected in [0,127] with low <= high, but the layout clamps defensively so a malformed
// zone never yields an out-of-strip rect.
struct EmbedZone {
int lowNote = 0;
int highNote = 127;
};
// The strip's regions, derived from the (w x h) embed area REAPER reports. Both clamp to
// the area so a degenerate (tiny) size never yields a region spilling outside the surface.
struct EmbedLayout {
Rect keymap; // top: the zone-segment band (the compact keymap)
Rect levelBand; // bottom: the thin level/activity indicator
};
// Divide a (w x h) embed area into the strip's regions. Pure: same inputs -> same layout.
// The level band takes a fixed height at the bottom (clamped so it never exceeds the area
// or starves the keymap below kEmbedKeymapMinHeight); the keymap takes the rest. A zero or
// negative size yields empty rects (no inversion).
EmbedLayout layoutEmbed(int w, int h);
// The horizontal sub-rectangle of the keymap band for a zone spanning [lowNote, highNote]
// (inclusive). The 128-key span maps linearly across keymap.width; the returned rect
// spans the half-open pixel range [x(lowNote), x(highNote+1)) so adjacent zones (e.g.
// 0..59 and 60..127) tile without a gap or overlap. Notes are clamped to [0,127] and low
// is clamped to <= high, so a malformed zone yields an in-band (possibly zero-width) rect,
// never an inverted one. Pure.
Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote);
// The zone a click at (x, y) lands on, given the zones in draw order, or -1 for a click
// outside the keymap band or on a key not covered by any zone. When zones overlap on a
// key, the FIRST covering zone in order wins — mirroring the sampler core's first-match
// Keymap::resolve and the editor's zone order, so selection agrees with playback. Pure.
int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x,
int y);
// The filled portion of the level band for a 0..1 level. Clamps level to [0,1]; the
// returned rect is the left sub-rectangle of levelBand whose width is level * band width
// (rounded down). level <= 0 -> empty rect; level >= 1 -> the whole band. Pure.
Rect levelFillRect(const EmbedLayout& layout, double level);
} // namespace reasampler::instrument::ui
+180
View File
@@ -0,0 +1,180 @@
// envelope_edit.cpp — see envelope_edit.h. Pure inverse map + hit-test; no host types.
#include "core/instrument/ui/envelope_edit.h"
#include <algorithm>
#include <cstdlib> // std::abs
namespace reasampler::instrument::ui {
namespace {
// Seconds represented by one horizontal pixel under the overlay's linear time base. Zero when the
// area is degenerate (the caller then produces no motion). Matches envelope_overlay::timeToX.
double secondsPerPixel(const Rect& area, double totalSeconds) {
const int w = std::max(0, area.width);
if (w <= 0 || totalSeconds <= 0.0) return 0.0;
return totalSeconds / static_cast<double>(w);
}
// Seconds per pixel for a GATE time-node drag (FA2): the reciprocal of the overlay's
// param-domain gatePxPerSecond(area) scale — sample-length-free, matching
// envelope_overlay::gatePolyline exactly so the dragged handle tracks the cursor 1:1 (each
// node's x is affine in its own segment duration with slope gatePxPerSecond). Zero when the
// area is degenerate.
double gateSecondsPerPixel(const Rect& area) {
const double pps = gatePxPerSecond(area);
return pps > 0.0 ? 1.0 / pps : 0.0;
}
// Level (0..1) represented by one vertical pixel. levelToY spans (height-1) rows for [0,1], so one
// pixel is 1/(height-1). Zero when degenerate. Matches envelope_overlay::levelToY.
double levelPerPixel(const Rect& area) {
const int h = std::max(0, area.height);
if (h <= 1) return 0.0;
return 1.0 / static_cast<double>(h - 1);
}
// True for the nodes the user can grab-and-drag (Origin + ReleaseStart are draw-only anchors).
bool isDraggable(EnvNode n) {
switch (n) {
case EnvNode::Origin:
case EnvNode::ReleaseStart:
return false;
default:
return true;
}
}
// True when the node belongs to the envelope's active mode. Guards the degenerate cross-mode
// write: the degenerate baseline polyline carries a ReleaseEnd vertex regardless of mode, so a
// zero-height Trigger-mode grab of it must not write releaseSeconds (and vice versa for Gate
// nodes vs Trigger fields). Applied by BOTH the hit-test and the drag resolver so they agree.
bool nodeInMode(EnvNode n, EnvMode m) {
switch (n) {
case EnvNode::AttackEnd:
case EnvNode::HoldEnd:
case EnvNode::DecayEnd:
case EnvNode::ReleaseEnd:
return m == EnvMode::Gate;
case EnvNode::FadeInEnd:
case EnvNode::FadeOutStart:
case EnvNode::LengthEnd:
return m == EnvMode::Trigger;
case EnvNode::Origin:
case EnvNode::ReleaseStart:
return false; // never draggable in any mode (isDraggable filters these anyway)
}
return false;
}
} // namespace
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y) {
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, area, totalSeconds);
// NEAREST draggable, mode-matching node within the pick radius wins (Chebyshev distance —
// the square grab box); ties break to the earlier draw-order node (FA2). Gate nodes never
// coincide (the forward map enforces kGateNodeSepPx separation), so the tie-break only
// matters for Trigger's zero-fade-out coincidence: FadeOutStart overlays LengthEnd, WINS the
// tie, and can be dragged inward from the right edge. The mode filter keeps the degenerate
// baseline's ReleaseEnd vertex from registering as a grabbable node in Trigger mode.
NodeHit best;
int bestDist = kNodeGrabRadius + 1;
for (const EnvVertex& v : poly) {
if (!isDraggable(v.node) || !nodeInMode(v.node, env.mode)) continue;
const int dist = std::max(std::abs(x - v.x), std::abs(y - v.y));
if (dist < bestDist) { // strictly closer only: earlier draw order keeps ties
bestDist = dist;
best = NodeHit{true, v.node};
}
}
return best;
}
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area,
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels) {
AmpEnvelope out = grabEnv;
if (!isDraggable(node) || !nodeInMode(node, grabEnv.mode)) return out;
const double secPerPx = secondsPerPixel(area, totalSeconds);
if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion
const double dSec = static_cast<double>(dxPixels) * secPerPx;
// Gate time nodes use the schematic's PARAM-DOMAIN px->seconds scale (FA2) — the reciprocal
// of the overlay's gatePxPerSecond, sample-length-free — so the dragged handle tracks the
// cursor 1:1. gateTimedWidth >= 1 whenever the area is non-empty, so gateDSec is
// well-defined past the degenerate guard above.
const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(area);
switch (node) {
// --- Gate: each cumulative-time node edits its OWN segment duration. Non-negative
// durations ARE the monotonic-in-time guarantee (a node can never cross a neighbour
// because every segment stays >= 0), so the [0, max] clamp is the whole constraint.
case EnvNode::AttackEnd:
out.attackSeconds =
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
break;
case EnvNode::HoldEnd:
out.holdSeconds = std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
break;
case EnvNode::DecayEnd: {
// Sustain node: X sets decay time, Y sets sustain level (drag DOWN = higher y = lower
// level, so subtract the level delta).
out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
const double lvlPerPx = levelPerPixel(area);
const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx;
out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
break;
}
case EnvNode::ReleaseEnd:
out.releaseSeconds =
std::clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds);
break;
// --- Trigger: fades + length are FRACTIONS. X pixels convert to a fraction of the PLAYED
// span (fades) or the whole sample (length). Monotonic: fadeIn + fadeOut <= 1 so the
// two fade nodes never cross (each clamps against the other), and length in [0, max].
//
// TRIGGER SEAM — CONVERSION REQUIRED ON BOTH PATHS (Wave 2 shell author, read this):
// fadeInFraction/fadeOutFraction in AmpEnvelope are fractions of the played span.
// TriggerParams (sampler_core.h) stores the corresponding values as SOURCE FRAMES
// (fadeInFrames/fadeOutFrames, int64_t). The shell owes a converter on BOTH directions:
// pack (draw): fadeInFrames/fadeOutFrames -> fraction (needs frameCount + rate)
// unpack (commit): fraction -> fadeInFrames/fadeOutFrames (same inputs)
// See the TRIGGER SEAM note on AmpEnvelope in envelope_overlay.h for the formula.
case EnvNode::FadeInEnd: {
if (dxPixels == 0) break; // zero-motion grab: no param change, no division
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds;
const double dFrac = playSeconds > 0.0 ? dSec / playSeconds : 0.0;
const double hi = std::min(bounds.maxFadeInFraction,
1.0 - std::max(0.0, grabEnv.fadeOutFraction));
out.fadeInFraction = std::clamp(grabEnv.fadeInFraction + dFrac, 0.0, std::max(0.0, hi));
break;
}
case EnvNode::FadeOutStart: {
if (dxPixels == 0) break; // zero-motion grab: no param change, no division
// FadeOutStart sits at (1 - fadeOut) of the played span; dragging it LEFT (negative dx)
// lengthens the fade-out. So the fade-out fraction moves OPPOSITE the pixel delta.
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds;
const double dFrac = playSeconds > 0.0 ? -dSec / playSeconds : 0.0;
const double hi = std::min(bounds.maxFadeOutFraction,
1.0 - std::max(0.0, grabEnv.fadeInFraction));
out.fadeOutFraction = std::clamp(grabEnv.fadeOutFraction + dFrac, 0.0, std::max(0.0, hi));
break;
}
case EnvNode::LengthEnd: {
// LengthEnd sits at lengthFraction of the WHOLE sample; X maps to a fraction of it.
const double dFrac = totalSeconds > 0.0 ? dSec / totalSeconds : 0.0;
out.lengthFraction = std::clamp(grabEnv.lengthFraction + dFrac, 0.0, bounds.maxLengthFraction);
break;
}
case EnvNode::Origin:
case EnvNode::ReleaseStart:
break; // unreachable (isDraggable filtered above), kept for switch exhaustiveness
}
return out;
}
} // namespace reasampler::instrument::ui
+108
View File
@@ -0,0 +1,108 @@
// envelope_edit.h — PURE node hit-test + pixel-delta→clamped-param inverse map for the S-VIEW-3
// draggable envelope nodes. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror
// of card_drag / waveform_view: the drag arithmetic + clamp/monotonic constraints live here,
// unit-tested at the boundaries outside the DAW, while the editor shell (reasampler_editor.cpp)
// draws the handles, captures the grab on WM_LBUTTONDOWN, feeds each move's pixel delta back
// through here, and commits the resulting params to the zone through the same off-audio-thread
// path a slider edit uses.
//
// TWO SURFACES, ONE MODEL. envelope_overlay owns the params→polyline FORWARD map (draw); this
// module owns the pixel→params INVERSE map (edit) + node hit-test. Both read/write the SAME
// AmpEnvelope fields (the shell re-reads the zone every paint — no listener chain), so a node
// drag and a slider edit are two views on one source of truth and can never diverge.
//
// THE INVARIANT (S-VIEW-F2). A drag can NEVER produce a param a slider couldn't:
// * MONOTONIC IN TIME — a node clamps between its time predecessor and successor, so attack-end
// can't pass hold-end, decay can't pass release, etc. Each segment stays >= 0.
// * RANGE-CLAMPED — times clamp to the SAME per-param [min,max] the slider enforces; levels
// clamp to [0,1]. Because the concrete second/fraction maxima live SHELL-SIDE (param_slider
// is deliberately engine-free — the shell owns the 0..1↔domain mapping), the clamp bounds are
// CALLER-SUPPLIED here (EnvClampBounds): the shell passes the same maxima it feeds the slider,
// so the two surfaces share one clamp by construction.
//
// WHICH AXES. Time-only nodes (AttackEnd, HoldEnd, ReleaseEnd; FadeInEnd, FadeOutStart,
// LengthEnd) drag on X only. The sustain node (DecayEnd) drags on BOTH axes — its X sets the
// decay time, its Y sets the sustain level (the standard ADSR-editor grammar). Origin and the
// drawing-only ReleaseStart vertex are NOT draggable.
//
// GATE DRAG SCALE (FA2). Gate time nodes convert px->seconds via the reciprocal of the
// schematic's PARAM-DOMAIN scale (envelope_overlay's gatePxPerSecond — sample-length-free), so
// a dragged handle tracks the cursor exactly 1:1 for stages within the schematic domain (each
// node's x is affine in its own segment duration). Trigger nodes keep the full-canvas
// PCM-aligned scale. Both match the forward map in envelope_overlay. A node is only editable in
// its OWN mode: Gate nodes ignore drags while the envelope is in Trigger mode and vice versa
// (guards the degenerate baseline's cross-mode ReleaseEnd vertex from writing releaseSeconds).
//
// Reuses editor_geometry's Rect + the EnvNode / AmpEnvelope / EnvMode types from
// envelope_overlay (one shared node vocabulary across draw + edit), and the shared timeToX /
// levelToY maps so the handle the overlay drew and the grab region here agree pixel-for-pixel.
#pragma once
#include <cstdint>
#include <vector>
#include "core/instrument/ui/editor_geometry.h" // Rect
#include "core/instrument/ui/envelope_overlay.h" // EnvNode, EnvMode, AmpEnvelope, EnvVertex, timeToX/levelToY
namespace reasampler::instrument::ui {
// The pick radius (px) around a node's drawn point: a grab within this many pixels (in BOTH x and
// y) of a node handle grabs it. Mirrors waveform_view's kMarkerGrabWidth — wide enough to grab a
// small handle comfortably, narrow enough that adjacent nodes stay distinguishable.
inline constexpr int kNodeGrabRadius = 6;
// The per-param clamp bounds the shell supplies (the SAME maxima its sliders map 0..1 onto). All
// are upper bounds in the param's own domain; the lower bound is 0 (each stage >= 0), and the
// monotonic-in-time constraint tightens these further at edit time. Defaults are conservative
// placeholders; the shell OVERRIDES them with its live slider domain so the clamp matches exactly.
struct EnvClampBounds {
double maxAttackSeconds = 4.0; // upper bound of the attack slider
double maxHoldSeconds = 4.0;
double maxDecaySeconds = 4.0;
double maxReleaseSeconds = 4.0;
// Trigger fades + length are fractions; their natural upper bound is 1.0. Exposed so a shell
// that caps a fade below the full span (e.g. 0.5) shares that cap with its slider.
double maxFadeInFraction = 1.0;
double maxFadeOutFraction = 1.0;
double maxLengthFraction = 1.0;
// sustainLevel is always [0,1] — no shell knob needed, kept implicit.
};
// Which node a grab at (x, y) lands on, given the CURRENT envelope + overlay rect + sample
// duration (the same inputs buildEnvelopePolyline drew from, so the grab tests the drawn handles).
// Returns EnvNode::Origin's NON-membership as a miss via the bool return: `hit` is false for a
// point off every DRAGGABLE node. Origin and ReleaseStart are never returned (not draggable),
// and a node from the OTHER mode is never returned (the degenerate baseline's ReleaseEnd vertex
// is not grabbable in Trigger mode). The NEAREST node within the radius wins (Chebyshev
// distance); an exact tie goes to the earlier draw-order node (FA2 — deterministic). Gate nodes
// never coincide (the forward map enforces kGateNodeSepPx separation, so every Gate handle is
// individually grabbable in every state); the tie-break matters only for Trigger's zero-fade-out
// coincidence, where FadeOutStart overlays LengthEnd, wins the tie, and can be dragged inward
// from the right edge. Pure.
struct NodeHit {
bool hit = false;
EnvNode node = EnvNode::Origin; // meaningful only when hit == true
};
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y);
// Resolve a drag of `node` to a new AmpEnvelope. Given the envelope AS OF GRAB TIME (`grabEnv` —
// the shell snapshots it on WM_LBUTTONDOWN so the delta is absolute, not accumulated), the overlay
// rect + sample duration (the pixel↔param maps), the caller's clamp bounds, and the pixel delta
// since grab (`dxPixels`, `dyPixels`), returns the envelope the node should now describe:
// * X delta -> the node's TIME param, shifted proportionally (same linear map as timeToX),
// clamped to [0, per-param max] AND to its monotonic-in-time neighbours (>= predecessor time,
// <= successor time). For a cumulative-time node the shift lands on that node's OWN segment
// duration (e.g. dragging HoldEnd changes holdSeconds, not attack).
// * Y delta -> the LEVEL param, but ONLY for the sustain node (DecayEnd); clamped to [0,1].
// dyPixels is IGNORED for every time-only node.
// * Non-draggable node (Origin / ReleaseStart), a node from the OTHER mode (a Gate node while
// grabEnv.mode is Trigger, or vice versa), a zero-width/zero-height area, or
// totalSeconds <= 0 -> `grabEnv` returned unchanged (no motion).
// Only the dragged node's param(s) change; every other field carries through from `grabEnv`. Pure
// — rounding is to the param's continuous value (no snapping, matching the sliders' resolution).
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area,
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels);
} // namespace reasampler::instrument::ui
+175
View File
@@ -0,0 +1,175 @@
// envelope_overlay.cpp — see envelope_overlay.h. Pure geometry; no host types.
#include "core/instrument/ui/envelope_overlay.h"
#include "core/util/clamp01.h"
#include <algorithm>
namespace reasampler::instrument::ui {
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24)
int timeToX(const Rect& area, double totalSeconds, double t) {
const int w = std::max(0, area.width);
if (w <= 0 || totalSeconds <= 0.0) return area.x;
if (t < 0.0) t = 0.0;
// Linear map, clamped on BOTH sides (FA2 bounds invariant): t past totalSeconds pins to the
// last in-bounds column area.right()-1. Clamp in DOUBLE space BEFORE the integer cast — a huge
// t would overflow a 32-bit long (Windows) and wrap to the WRONG edge — then round.
double px = (t / totalSeconds) * static_cast<double>(w);
if (px > static_cast<double>(w - 1)) px = static_cast<double>(w - 1);
return area.x + static_cast<int>(px + 0.5);
}
int gateTimedWidth(const Rect& area) {
const int w = std::max(0, area.width);
if (w <= 0) return 0;
const int sustainPx =
static_cast<int>(kGateSustainDisplayFraction * static_cast<double>(w) + 0.5);
return std::max(1, w - sustainPx);
}
double gatePxPerSecond(const Rect& area) {
const int timedW = gateTimedWidth(area);
if (timedW <= 0) return 0.0;
// Usable width = timed region minus the four per-segment separation bases and the last
// in-bounds column, floored at 1 px so the scale never degenerates; the domain is the four
// stages end-to-end at their schematic maxima (param-domain scale — sample-length-free).
const double usable =
std::max(1.0, static_cast<double>(timedW - 1 - 4 * kGateNodeSepPx));
return usable / (4.0 * kGateStageMaxSeconds);
}
int levelToY(const Rect& area, double level) {
const int h = std::max(0, area.height);
if (h <= 0) return area.y;
if (level < 0.0) level = 0.0;
if (level > 1.0) level = 1.0;
// Level 1 -> top row, level 0 -> bottom row (bottom-1 under the half-open convention). The
// range spans (h-1) pixels so both endpoints land ON a drawable row.
const int span = h - 1;
const long dy = static_cast<long>((1.0 - level) * static_cast<double>(span) + 0.5);
return area.y + static_cast<int>(dy);
}
namespace {
EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, double level) {
EnvVertex v;
v.node = node;
v.x = timeToX(area, totalSeconds, t);
v.y = levelToY(area, level);
v.level = level;
return v;
}
// One Gate vertex from a pixel offset inside the area (the Gate schematic works in px space —
// timed px + the fixed sustain-plateau reserve — not through the plain timeToX map). Clamps x in
// DOUBLE space to the last in-bounds column BEFORE the integer cast (FA2 bounds invariant; a
// huge px would overflow a 32-bit long on Windows and wrap to the WRONG edge).
EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
const int w = std::max(1, area.width);
if (px < 0.0) px = 0.0;
if (px > static_cast<double>(w - 1)) px = static_cast<double>(w - 1);
EnvVertex v;
v.node = node;
v.x = area.x + static_cast<int>(px + 0.5);
v.y = levelToY(area, level);
v.level = level;
return v;
}
std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
// Non-negative segment durations (a stored negative would be an upstream bug; clamp defensively).
const double a = std::max(0.0, env.attackSeconds);
const double h = std::max(0.0, env.holdSeconds);
const double d = std::max(0.0, env.decaySeconds);
const double r = std::max(0.0, env.releaseSeconds);
const double sus = clamp01(env.sustainLevel);
// BOUNDED SCHEMATIC (FA2): A/H/D and R map onto the TIMED region (canvas minus the reserved
// sustain-plateau width) at the PARAM-DOMAIN scale — sample-length-free — and every segment
// gets a kGateNodeSepPx base so consecutive nodes never coincide (every node individually
// grabbable at any params, incl. the tier-0 zero-hold/zero-decay defaults). The sustain
// plateau is the fixed reserve between DecayEnd and ReleaseStart.
const int W = std::max(1, area.width);
const double sustainPx = static_cast<double>(W - gateTimedWidth(area));
const double sep = static_cast<double>(kGateNodeSepPx);
const double pps = gatePxPerSecond(area);
double xAttack = sep + a * pps; // AttackEnd
double xHold = xAttack + sep + h * pps; // HoldEnd
double xDecay = xHold + sep + d * pps; // DecayEnd (sustain node)
double xPlateau = xDecay + sustainPx; // ReleaseStart (schematic note-off)
double xRelease = xPlateau + sep + r * pps; // ReleaseEnd
// Right-edge overrun (a stored stage beyond the schematic domain): compress from the RIGHT
// preserving the minimum gaps, so trailing nodes stay individually separated instead of
// piling on the last column. The re-floor pass only bites when the canvas is too narrow to
// hold the minimum gaps at all — then gateVtx's [0, W-1] clamp wins (in-bounds > separation).
const double xMax = static_cast<double>(W - 1);
if (xRelease > xMax) {
xRelease = xMax;
xPlateau = std::min(xPlateau, xRelease - sep);
xDecay = std::min(xDecay, xPlateau - sustainPx);
xHold = std::min(xHold, xDecay - sep);
xAttack = std::min(xAttack, xHold - sep);
xAttack = std::max(xAttack, sep);
xHold = std::max(xHold, xAttack + sep);
xDecay = std::max(xDecay, xHold + sep);
xPlateau = std::max(xPlateau, xDecay + sustainPx);
xRelease = std::max(xRelease, xPlateau + sep);
}
std::vector<EnvVertex> pts;
pts.reserve(6);
pts.push_back(gateVtx(EnvNode::Origin, area, 0.0, 0.0));
pts.push_back(gateVtx(EnvNode::AttackEnd, area, xAttack, 1.0));
pts.push_back(gateVtx(EnvNode::HoldEnd, area, xHold, 1.0));
pts.push_back(gateVtx(EnvNode::DecayEnd, area, xDecay, sus)); // sustain node
pts.push_back(gateVtx(EnvNode::ReleaseStart, area, xPlateau, sus)); // plateau end
pts.push_back(gateVtx(EnvNode::ReleaseEnd, area, xRelease, 0.0));
return pts;
}
std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds) {
// The played span is lengthFraction of the whole sample; fades are fractions OF that span.
const double len = clamp01(env.lengthFraction);
double fadeIn = clamp01(env.fadeInFraction);
double fadeOut = clamp01(env.fadeOutFraction);
// Fades cannot overlap: clamp so fadeIn + fadeOut <= 1 (of the played span), mirroring the
// engine's TriggerParams clamp. Trim the LATER fade (fade-out) first, matching the engine.
if (fadeIn + fadeOut > 1.0) fadeOut = std::max(0.0, 1.0 - fadeIn);
const double playSeconds = len * totalSeconds;
const double tFadeInEnd = fadeIn * playSeconds;
const double tFadeOutStart = playSeconds - fadeOut * playSeconds; // where fade-out begins
std::vector<EnvVertex> pts;
pts.reserve(4);
pts.push_back(vtx(EnvNode::Origin, area, totalSeconds, 0.0, 0.0));
pts.push_back(vtx(EnvNode::FadeInEnd, area, totalSeconds, tFadeInEnd, 1.0));
pts.push_back(vtx(EnvNode::FadeOutStart, area, totalSeconds, tFadeOutStart, 1.0)); // unity end
pts.push_back(vtx(EnvNode::LengthEnd, area, totalSeconds, playSeconds, 0.0)); // playEnd
return pts;
}
} // namespace
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds) {
if (area.width <= 0 || area.height <= 0 || totalSeconds <= 0.0) {
// Degenerate surface: a two-point flat baseline at level 0 so the shell always has a line.
return {vtx(EnvNode::Origin, area, 1.0, 0.0, 0.0),
vtx(EnvNode::ReleaseEnd, area, 1.0, 1.0, 0.0)};
}
// Gate is a param-domain schematic — totalSeconds only gates the degenerate branch above
// (no loaded duration -> baseline); Trigger is PCM-aligned and consumes it.
return env.mode == EnvMode::Gate ? gatePolyline(env, area)
: triggerPolyline(env, area, totalSeconds);
}
} // namespace reasampler::instrument::ui
+229
View File
@@ -0,0 +1,229 @@
// envelope_overlay.h — PURE amp-envelope → polyline geometry for the S-VIEW-3 Sample-view
// envelope overlay. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of
// waveform_view / param_slider: the params→pixel polyline math lives here, unit-tested outside
// the DAW, while the editor shell (reasampler_editor.cpp) traces the polyline in an accent hue
// and draws the node handles (via envelope_edit's hit-test).
//
// WHAT IT DRAWS. The amp envelope over the Sample view's hero waveform (Simpler / Phase-Plant
// grammar):
// * Gate -> the AHDSR shape: attack ramp 0->1, hold plateau at 1, decay 1->sustain,
// sustain plateau, release sustain->0. Since there is no held note-off to draw
// against, Gate is a BOUNDED SCHEMATIC (FA2): a fixed fraction of the canvas
// width (kGateSustainDisplayFraction) is RESERVED for the sustain plateau, and
// the remaining "timed" width carries A/H/D AND the release at the PARAM-DOMAIN
// scale — the timed width represents 4 x kGateStageMaxSeconds (the four stage
// sliders end-to-end at their maxima), NOT the sample's duration, so the layout
// is identical for a 0.3s and a 10s capture. Each segment additionally gets a
// kGateNodeSepPx pixel base, so consecutive nodes NEVER coincide: every Gate
// node is individually grabbable at ANY param values, including the tier-0
// defaults (hold 0 / decay 0). A -> (H) -> D -> S-plateau -> R all render INSIDE
// the canvas and the release is a visible, draggable segment.
// * Trigger -> the fade/%-length shape: fade-in 0->1, unity plateau, fade-out 1->0 anchored
// to playEnd (= lengthFraction of the post-start span). Trigger keeps the
// waveform's exact time base so the shape lines up with the PCM under it.
// The horizontal axis is TIME (Gate: schematic, see above; Trigger: wall-clock across the rect);
// the vertical axis is LEVEL (0 at rect bottom, 1 at rect top).
//
// BOUNDS INVARIANT (FA2). EVERY vertex of EVERY polyline is clamped inside the canvas:
// x in [area.x, area.right()-1], y in [area.y, area.bottom()-1] (half-open rect convention).
// No node and no drawn segment ever exceeds the canvas — paint-time clipping of handles is no
// longer needed (and never fires) in the shell.
//
// FA2 CONTRACT CHANGE — WAVE B SHELL AUTHOR, READ THIS:
// * The EnvNode enum is UNCHANGED (same node set, same draggable set — Origin + ReleaseStart
// remain the only non-draggable anchors).
// * ALL vertices are now in-bounds (see above). The shell's previous "skip handle when
// v.x >= waveArea.right()" clip is dead code: ReleaseEnd (Gate) and FadeOutStart/LengthEnd
// (Trigger, at full length / zero fade-out) now land at area.right()-1 and MUST get handles.
// * Gate's x-axis is SCHEMATIC, not PCM-aligned: the timed region is scaled to the param
// domain (4 x kGateStageMaxSeconds), the sustain reserve is a fixed width, and every
// segment carries a kGateNodeSepPx pixel base. The Gate curve does NOT line up with the
// waveform under it — do not label it as if it did. Trigger's x-axis IS still PCM-aligned.
// * Gate nodes never coincide (min-separation, above), so every Gate handle is individually
// grabbable in every state. nodeAtPoint (envelope_edit) resolves to the NEAREST node within
// the grab radius with a draw-order tie-break; the tie-break only matters for the one
// remaining coincidence, Trigger's zero-fade-out (FadeOutStart overlays LengthEnd at the
// right edge and wins the tie, so the fade can be dragged open from zero).
//
// DELIBERATELY ENGINE-FREE (house pattern — param_slider does the same). It does NOT depend on
// sample_map / sampler_core (which would drag bank_book / wav_trim in). The shell reads the
// zone's AdsrSeconds / TriggerParams and packs them into the small AmpEnvelope view struct here.
// AHDSR times are wall-clock SECONDS (rate-free, matching the stored domain — Daniel's no-
// hardcoded-rate ruling); Trigger fades are FRACTIONS of the play span. The one rate-bound input
// is the total sample duration in seconds, which the shell resolves once from the live rate and
// the frame count and passes in — this module never sees a sample rate.
//
// It reuses editor_geometry's Rect + contains(), the one shared geometry idiom.
#pragma once
#include <cstdint>
#include <vector>
#include "core/instrument/ui/editor_geometry.h" // Rect — the shared geometry idiom
namespace reasampler::instrument::ui {
// The play mode the overlay draws — a LOCAL mirror of sampler_core's PlayMode kept here so the
// geometry module stays engine-free (the shell maps the zone's PlayMode to this). Same two cases.
enum class EnvMode { Gate, Trigger };
// Which breakpoint a polyline vertex / node is. The shell draws a draggable handle at each of
// these; envelope_edit hit-tests against them. Kept in one enum shared by overlay + edit so the
// forward map (draw) and inverse map (edit) name the same nodes.
//
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(=sustain corner) -> ReleaseStart
// -> ReleaseEnd. The sustain node is DecayEnd (its Y is the sustain level);
// ReleaseStart is a drawing-only plateau-end vertex (the schematic note-off);
// release is edited by dragging ReleaseEnd.
// Trigger nodes: Origin -> FadeInEnd -> FadeOutStart -> LengthEnd(playEnd, level 0). The fade-out
// ramp is the FadeOutStart->LengthEnd segment; LengthEnd is the playEnd terminal.
enum class EnvNode {
Origin, // t=0, level 0 (both modes) — not draggable (fixed anchor)
AttackEnd, // Gate: top of the attack ramp (level 1) — X sets attackSeconds
HoldEnd, // Gate: end of the hold plateau (level 1) — X sets holdSeconds
DecayEnd, // Gate: decay settles to sustain — the SUSTAIN node (X sets decaySeconds,
// Y sets sustainLevel)
ReleaseStart, // Gate: end of the sustain plateau / start of the release (sustain level) —
// a DRAWING vertex only, not a draggable handle (release is edited at
// ReleaseEnd; this vertex sits a fixed sustain-plateau width right of
// DecayEnd — the schematic note-off — Y = sustain level)
ReleaseEnd, // Gate: end of the release tail (level 0) — X sets releaseSeconds
FadeInEnd, // Trigger: top of the fade-in (level 1) — X sets fadeInFraction
FadeOutStart, // Trigger: end of the unity plateau / start of the fade-out (level 1) —
// X sets fadeOutFraction
LengthEnd, // Trigger: the playEnd terminal / %-length (level 0) — X sets lengthFraction
};
// The amp-envelope params the overlay draws — the small view struct the shell packs from the
// zone's stored AdsrSeconds / TriggerParams. Engine-free by design (no sampler_core include).
//
// Gate fields (SECONDS, wall-clock): attack / hold / decay / release; sustain is a LEVEL 0..1.
// These map 1-to-1 with the stored AdsrSeconds fields — no conversion required.
//
// Trigger fields (FRACTIONS of play): fadeIn / fadeOut as a fraction of the played span;
// lengthFraction is the played span as a fraction of the
// post-start sample length (matching TriggerParams).
//
// TRIGGER SEAM — CONVERSION REQUIRED ON BOTH PATHS (Wave 2 shell author, read this):
// TriggerParams (sampler_core.h) stores Trigger fades as SOURCE FRAMES:
// fadeInFrames (int64_t) — 0->1 ramp length in source frames
// fadeOutFrames (int64_t) — 1->0 ramp length in source frames
// AmpEnvelope stores them as FRACTIONS of the played span:
// fadeInFraction = fadeInFrames / playLengthFrames
// fadeOutFraction = fadeOutFrames / playLengthFrames
// where playLengthFrames = round(lengthFraction * (frameCount - startFrame)).
// This is a NON-TRIVIAL derived view — NOT a direct field copy. The shell owes a
// converter on BOTH directions:
// PACK (draw): frames -> fraction (TriggerParams -> AmpEnvelope, needs frameCount + rate)
// UNPACK (commit): fraction -> frames (AmpEnvelope -> TriggerParams, same inputs)
// lengthFraction maps 1-to-1 with TriggerParams::lengthFraction and needs no conversion.
//
// Unused fields for the active mode are ignored.
struct AmpEnvelope {
EnvMode mode = EnvMode::Gate;
// Gate (AHDSR), seconds + a dimensionless sustain level.
double attackSeconds = 0.003;
double holdSeconds = 0.0;
double decaySeconds = 0.0;
double sustainLevel = 1.0;
double releaseSeconds = 0.060;
// Trigger, fractions of the play span (fadeIn/fadeOut) and of the post-start length.
// NOTE: fadeInFraction/fadeOutFraction are DERIVED from TriggerParams::fadeInFrames/
// fadeOutFrames — see the TRIGGER SEAM note above. A converter is owed on both the
// pack (draw) and unpack (commit) paths; these fields are NOT a direct TriggerParams copy.
double lengthFraction = 1.0; // (0,1] of the post-start span that plays (1-to-1 with TriggerParams)
double fadeInFraction = 0.0; // 0->1 ramp as a fraction of the played span (DERIVED — see above)
double fadeOutFraction = 0.0; // 1->0 ramp as a fraction of the played span (DERIVED — see above)
};
// One polyline vertex: a pixel point plus which node it is. The shell draws a line through the
// points in order (the amp curve) and a draggable handle at each vertex whose node is not Origin.
// Level is carried alongside (0..1) for callers that want to label/inspect; it is redundant with y.
struct EnvVertex {
EnvNode node = EnvNode::Origin;
int x = 0; // pixel x inside the overlay rect
int y = 0; // pixel y inside the overlay rect (top = level 1, bottom = level 0)
double level = 0.0; // 0..1, the vertex's amplitude (redundant with y; for inspection)
bool operator==(const EnvVertex& o) const {
return node == o.node && x == o.x && y == o.y && level == o.level;
}
};
// The fraction of the canvas width RESERVED for the Gate sustain-plateau display (FA2). The
// plateau is a fixed-width schematic region between DecayEnd and ReleaseStart; the remaining
// width is the "timed" region A/H/D/R map onto at the schematic param-domain scale. One
// constant shared by the forward map (here) and the inverse map (envelope_edit).
inline constexpr double kGateSustainDisplayFraction = 0.15;
// The minimum pixel separation between consecutive Gate polyline nodes: every Gate segment gets
// this many px as a base, PLUS its time-proportional extent, so zero-duration stages (tier-0
// defaults: hold 0, decay 0) still render as distinct, individually grabbable handles. Chosen
// larger than envelope_edit's kNodeGrabRadius (6) so a click dead-on a node can never tie with
// its neighbour. Shared by the forward map and the drag inverse.
inline constexpr int kGateNodeSepPx = 8;
// The Gate schematic's per-stage time domain (seconds): the timed region represents the four
// stages end-to-end at this maximum each (4 x this total). MIRRORS the shell's stage-slider
// ceiling (kEnvTimeMaxSeconds in reasampler_editor.cpp) — keep the two equal so a stage at its
// slider max lands exactly at the canvas edge. Drag safety does NOT depend on this constant
// (param clamps are caller-supplied in envelope_edit); only layout does.
inline constexpr double kGateStageMaxSeconds = 2.0;
// The pixel width of the Gate timed region: area.width minus the sustain-plateau reserve,
// floored at 1 px so the px<->seconds scale never degenerates for a non-empty area. Returns 0
// for a zero/negative-width area. Shared by gatePolyline and envelope_edit's gate drag scale.
int gateTimedWidth(const Rect& area);
// Pixels per second of the Gate timed region under the PARAM-DOMAIN scale: the timed width,
// minus the four per-segment kGateNodeSepPx bases and the last in-bounds column, spread over
// 4 x kGateStageMaxSeconds. Independent of the sample's duration. Returns 0 for a
// zero/negative-width area; otherwise > 0 (the usable width floors at 1 px). The ONE px<->sec
// scale shared by the forward map (gatePolyline) and the drag inverse (envelope_edit), so a
// dragged handle tracks the cursor 1:1.
double gatePxPerSecond(const Rect& area);
// Map an amp envelope to its polyline vertices inside `area`, over a sample of `totalSeconds`
// wall-clock duration. `area` is the waveform rect (left/top inclusive, right/bottom exclusive);
// y maps level 0..1 across [area.bottom()-1 .. area.y] (level 1 at the TOP). The polyline reads
// left-to-right in draw order, Origin first.
//
// TIME BASE (FA2).
// * Gate: a bounded schematic, INDEPENDENT of totalSeconds. The canvas splits into a TIMED
// region of gateTimedWidth(area) px — where attack/hold/decay run from t=0 and the release
// ramp runs after the plateau, at the gatePxPerSecond(area) PARAM-DOMAIN scale, each segment
// carrying a kGateNodeSepPx base so consecutive nodes never coincide — plus a FIXED sustain
// plateau of (width - timedWidth) px between DecayEnd and ReleaseStart (the schematic
// note-off). Stages beyond the schematic domain (a stored stage > kGateStageMaxSeconds)
// compress from the RIGHT preserving the minimum gaps, so trailing nodes stay individually
// separated instead of piling on the last column; only a canvas too narrow to hold the
// minimum gaps at all sacrifices separation (in-bounds wins).
// * Trigger: the waveform's exact time base (PCM-aligned). The played span is
// lengthFraction * totalSeconds; fade-in/out are fractions OF that played span. Nodes past
// the played span never appear (FadeOutStart/LengthEnd sit at the played span's right edge).
//
// BOUNDS: every vertex is inside the canvas — x in [area.x, area.right()-1], y in
// [area.y, area.bottom()-1]. Nothing maps past area.right() (the pre-FA2 release tail is gone). A
// degenerate area (zero width/height) or totalSeconds <= 0 yields the two-point flat baseline
// [Origin, end at level 0] so the shell always has a drawable line. Pure — same inputs, same
// polyline.
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds);
// Map a time (seconds) to a pixel x inside `area`: t=0 -> area.x, t=totalSeconds ->
// area.right()-1, linear, CLAMPED on both sides (t < 0 pins to area.x; t past totalSeconds pins
// to area.right()-1 — the in-bounds invariant, FA2). A zero-width area or totalSeconds <= 0 yields
// area.x. Pure — the shared time->x map the Trigger polyline and the node hit-test
// (envelope_edit) use, so the drawn handle and its grab region agree.
int timeToX(const Rect& area, double totalSeconds, double t);
// Map a level (0..1) to a pixel y inside `area`: level 1 -> area.y, level 0 -> area.bottom()-1
// (so the full-amplitude line sits at the top edge and silence at the bottom pixel row). level is
// clamped to [0,1]. A zero-height area yields area.y. Pure — the shared level->y map the polyline
// and the node hit-test share.
int levelToY(const Rect& area, double level);
} // namespace reasampler::instrument::ui
+149
View File
@@ -0,0 +1,149 @@
// keyboard_strip.cpp — see keyboard_strip.h. Pure math; no host types.
#include "core/instrument/ui/keyboard_strip.h"
#include <algorithm>
namespace reasampler::instrument::ui {
namespace {
int clampNote(int n) {
if (n < 0) return 0;
if (n > kStripKeyCount - 1) return kStripKeyCount - 1;
return n;
}
// Map a key BOUNDARY in [0, kStripKeyCount] to an x pixel inside a band of the given
// left/width. keyEdge is a boundary (0..128): 0 -> band left, 128 -> band right. Integer
// math, floored — key N's left is keyEdgeToX(N) and its right is keyEdgeToX(N+1), tiling
// adjacent keys/zones without a seam (mirror of embed_strip::keyEdgeToX).
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
if (keyEdge <= 0) return bandLeft;
if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth;
return bandLeft + (keyEdge * bandWidth) / kStripKeyCount;
}
} // namespace
StripLayout layoutStrip(int w, int h) {
const int cw = std::max(0, w);
const int ch = std::max(0, h);
StripLayout out;
out.keys = Rect::ltrb(0, 0, cw, ch);
return out;
}
int keyLeftX(const StripLayout& layout, int note) {
const Rect& band = layout.keys;
const int bandWidth = std::max(0, band.width);
// note is a KEY here (0..127); its left edge is boundary `note`. Callers pass note+1 to
// get a key's right edge, and 128 maps to the band right.
const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note);
return keyEdgeToX(band.x, bandWidth, edge);
}
Rect keyRect(const StripLayout& layout, int note) {
const int n = clampNote(note);
const int leftX = keyLeftX(layout, n);
const int rightX = keyLeftX(layout, n + 1);
return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom());
}
Rect rootMarkerRect(const StripLayout& layout, int rootNote) {
return keyRect(layout, rootNote);
}
int keyAtPoint(const StripLayout& layout, int x, int y) {
const Rect& band = layout.keys;
if (!contains(band, x, y)) return -1;
const int bandWidth = std::max(0, band.width);
if (bandWidth <= 0) return -1;
// Invert keyEdgeToX: the key whose half-open [leftX, rightX) contains x. Floor-divide
// the pixel offset back to a key; clamp defensively (a point on band.right()-1 maps to 127).
const int offset = x - band.x;
int note = (offset * kStripKeyCount) / bandWidth;
return clampNote(note);
}
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote) {
int lo = clampNote(lowNote);
int hi = clampNote(highNote);
if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts
const int leftX = keyLeftX(layout, lo);
const int rightX = keyLeftX(layout, hi + 1);
return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom());
}
ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y) {
const Rect bar = zoneBarRect(layout, lowNote, highNote);
if (!contains(bar, x, y)) return ZoneGrab::kNone;
const int barW = bar.width;
// A narrow bar (< 2*edge) has no body: split at the midpoint, LOW edge wins the tie so
// a click exactly on the midpoint resizes low (deterministic).
if (barW < 2 * kStripEdgeGrabWidth) {
const int mid = bar.x + barW / 2;
return x <= mid ? ZoneGrab::kLowEdge : ZoneGrab::kHighEdge;
}
if (x < bar.x + kStripEdgeGrabWidth) return ZoneGrab::kLowEdge;
if (x >= bar.right() - kStripEdgeGrabWidth) return ZoneGrab::kHighEdge;
return ZoneGrab::kBody;
}
ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs,
int count, int x, int y) {
if (count <= 0 || lows == nullptr || highs == nullptr) return ZoneBarHit{};
if (!contains(layout.keys, x, y)) return ZoneBarHit{};
for (int i = 0; i < count; ++i) {
const ZoneGrab g = zoneGrabAt(layout, lows[i], highs[i], x, y);
if (g != ZoneGrab::kNone) return ZoneBarHit{i, g};
}
return ZoneBarHit{}; // on the band but on no bar
}
bool isNaturalKey(int note) {
// Clamp to the valid MIDI range before indexing.
const int n = note < 0 ? 0 : (note > kStripKeyCount - 1 ? kStripKeyCount - 1 : note);
// The 12-semitone pattern of natural (white) keys within an octave, starting at C:
// positions 0(C) 2(D) 4(E) 5(F) 7(G) 9(A) 11(B) are natural;
// positions 1(C#) 3(D#) 6(F#) 8(G#) 10(A#) are accidental.
static constexpr bool kNatural[12] = {
true, // 0 C
false, // 1 C#
true, // 2 D
false, // 3 D#
true, // 4 E
true, // 5 F
false, // 6 F#
true, // 7 G
false, // 8 G#
true, // 9 A
false, // 10 A#
true, // 11 B
};
return kNatural[n % 12];
}
int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) {
if (dxPixels == 0) return clampNote(startNote);
const int bandWidth = std::max(0, layout.keys.width);
if (bandWidth <= 0) return clampNote(startNote); // zero-width -> no motion
// Proportional shift: same linear mapping as keyAtPoint/keyEdgeToX so click and drag
// agree across the full strip, even on non-divisible-by-128 widths. The proportional
// key width is (bandWidth / kStripKeyCount) in exact rational arithmetic; rounding to
// the nearest key (half-key drag flips at the key centre) is achieved by adding
// bandWidth/2 to the absolute pixel delta before dividing — identical to the old
// formula except keyWidth is now derived from the same linear map (exact rational)
// rather than the truncated-integer bandWidth/128 that caused drift at the far end.
const int half = bandWidth / 2;
int shift;
if (dxPixels > 0) {
shift = (dxPixels * kStripKeyCount + half) / bandWidth;
} else {
shift = -(((-dxPixels) * kStripKeyCount + half) / bandWidth);
}
return clampNote(startNote + shift);
}
} // namespace reasampler::instrument::ui
+131
View File
@@ -0,0 +1,131 @@
// keyboard_strip.h — PURE layout + hit-test + drag math for the S10 capture-first
// editor's keyboard strip. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary.
// The mirror of editor_geometry / embed_strip / mode_switch: the fiddly rectangle +
// note-mapping arithmetic lives here so it is unit-tested outside the DAW, while the
// editor shell (reasampler_editor.cpp) draws the strip and marshals mouse events into
// these functions.
//
// The strip maps the full 128-key MIDI span across a horizontal band (the same key-span
// idiom embed_strip uses). It serves TWO faces of the S10 editor:
// * the SINGLE-CAPTURE fast path (default): one loaded capture with a ROOT MARKER on
// the strip, click-a-key (or drag the marker) sets the capture's root note; and
// * the opt-in ZONES panel (S10-Z, demoted): each performance zone drawn as a bar over
// the keys it covers, with edge-grab resize handles + a body move-handle so a drag
// sets low/high (edges) or moves the span (body), and a key-click sets the zone root.
//
// All interaction resolves through the pure DRAG-DELTA resolver here: the shell captures
// a grab on WM_LBUTTONDOWN, feeds each WM_MOUSEMOVE's pixel delta back through
// resolveDragNote, and commits the resolved note(s) on WM_LBUTTONUP. Live feedback is the
// shell re-drawing the in-flight note; one coherent edit lands on release.
//
// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom),
// so this header depends on editor_geometry.h rather than redefining a rectangle type.
#pragma once
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
namespace reasampler::instrument::ui {
// The full MIDI key span the strip maps across its width: 128 keys (0..127). Named
// distinctly from embed_strip's kEmbedKeyCount (same value) so the two strips stay
// independent — the editor strip may grow octave labels/metrics the embed strip never does.
inline constexpr int kStripKeyCount = 128;
// The width (px) of an edge-grab hit region at each end of a zone bar: a drag started
// within this many pixels of the bar's left/right edge resizes that edge; a drag started
// anywhere else on the bar moves the whole span. A zone narrower than 2*this has no body
// move-handle (both edges win their halves) — deliberate: a 1-key zone is all edges.
inline constexpr int kStripEdgeGrabWidth = 6;
// The strip's regions, derived from the (w x h) band the shell allots it. The keys band
// takes the whole area today (a future octave-label lane can carve a sub-band here without
// changing callers). Clamped so a degenerate (tiny/zero) size never yields an inverted rect.
struct StripLayout {
Rect keys; // the key band: the 128-key span maps linearly across keys.width
};
// Divide a (w x h) strip area into its regions. Pure: same inputs -> same layout. A zero or
// negative size yields empty rects (no inversion).
StripLayout layoutStrip(int w, int h);
// The x pixel (inside the keys band) of the LEFT edge of key `note` (0..127). The 128-key
// span maps linearly across keys.width; key N occupies the half-open pixel range
// [keyLeftX(N), keyLeftX(N+1)). Notes are clamped to [0,127]; note==128 maps to the band's
// right edge (so a key's right edge is keyLeftX(note+1)). Pure.
int keyLeftX(const StripLayout& layout, int note);
// The half-open pixel rect of a single key `note` (0..127): [keyLeftX(note),
// keyLeftX(note+1)) horizontally, the full keys-band height. A malformed (out-of-range)
// note clamps to [0,127]. Pure.
Rect keyRect(const StripLayout& layout, int note);
// The rect of the ROOT MARKER for the single-capture fast path: the key cell of `rootNote`,
// drawn as a highlighted key. Equivalent to keyRect(layout, rootNote) — a named entry point
// so the shell's intent (this is the root marker, not just any key) reads at the call site,
// and so a future marker shape (a triangle over the key) has one place to change. Pure.
Rect rootMarkerRect(const StripLayout& layout, int rootNote);
// The MIDI note a point (x, y) lands on, or -1 for a point outside the keys band. Backs
// click-to-set-root (single capture) and click-a-key-sets-zone-root (zones). Pure.
int keyAtPoint(const StripLayout& layout, int x, int y);
// The horizontal sub-rect of the keys band for a zone spanning [lowNote, highNote]
// (inclusive): [keyLeftX(low), keyLeftX(high+1)) horizontally, the full band height. Notes
// clamp to [0,127] and low clamps to <= high, so a malformed zone yields an in-band
// (possibly zero-width) rect, never an inverted one. Mirrors embed_strip::zoneSegmentRect.
// Pure.
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote);
// Which part of a zone bar a grab landed on. The shell uses this to decide what a drag
// edits: an edge resizes that boundary; the body moves the whole span; none means the grab
// missed the bar entirely (the shell may treat that as a key-click to set the root, or as a
// deselect).
enum class ZoneGrab {
kNone, // the point is not on this zone's bar
kLowEdge, // within kStripEdgeGrabWidth of the bar's LEFT edge -> resize low
kHighEdge, // within kStripEdgeGrabWidth of the bar's RIGHT edge -> resize high
kBody, // on the bar but not an edge -> move the whole span
};
// Classify a grab at (x, y) against ONE zone's bar (low..high). Returns kNone when the
// point is off the bar (or off the keys band). On the bar: kLowEdge/kHighEdge when within
// kStripEdgeGrabWidth of that edge, else kBody. A narrow bar (< 2*kStripEdgeGrabWidth)
// resolves the near half to each edge (no body). The LOW edge wins a tie at the exact
// midpoint of a narrow bar (deterministic). Pure.
ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y);
// The zone (index into `lows`/`highs`, draw order) whose bar a grab at (x, y) lands on,
// plus which part of it, or {-1, kNone} for a point off every bar. First covering zone in
// draw order wins (first-match, mirroring the core's Keymap::resolve + embed_strip). The
// arrays are parallel (lows[i]/highs[i] is zone i's inclusive range); `count` is their
// length. Pure — no host containers at the boundary (a raw pointer pair, like
// embed_strip::zoneAtPoint).
struct ZoneBarHit {
int zoneIndex = -1;
ZoneGrab grab = ZoneGrab::kNone;
};
ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs,
int count, int x, int y);
// Resolve a drag to a new MIDI note. Given the note the grabbed field held at grab time
// (`startNote`) and the horizontal pixel delta since grab (`dxPixels`), returns the note
// the field should now hold: startNote shifted by round(dxPixels / keyWidth), clamped to
// [0,127]. keyWidth is derived from the layout (band width / 128); a zero-width band pins
// the result to startNote (no motion). This is the single arithmetic behind edge-resize,
// body-move (apply to both edges with the SAME delta so the span is preserved), and
// root-marker drag. Pure — rounding is to the nearest key so a half-key drag flips at the
// key centre. Returns startNote unchanged for dxPixels==0.
int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels);
// Returns true when `note` (0..127) is a NATURAL (white) key in standard 12-tone equal
// temperament; false when it is an ACCIDENTAL (black) key. Notes out of the [0,127]
// range are clamped to [0,127] before classification (i.e. this never throws/UBs on a
// bad input). The 12 semitone positions within an octave:
// Natural (white): 0(C) 2(D) 4(E) 5(F) 7(G) 9(A) 11(B)
// Accidental (black): 1(C#) 3(D#) 6(F#) 8(G#) 10(A#)
// Used by the shell to overlay the two-tone bright/dark piano-key pattern over the
// pastel spectral fill (S-VIEW-7). Pure — no layout required, no host types.
bool isNaturalKey(int note);
} // namespace reasampler::instrument::ui
+156
View File
@@ -0,0 +1,156 @@
// knob_deck.cpp — see knob_deck.h. Pure arithmetic; no LICE/VST3/REAPER includes.
#include "core/instrument/ui/knob_deck.h"
#include <algorithm>
namespace reasampler::instrument::ui {
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<int>(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.y + kDeckGroupPadY;
const int innerLeft = box.x + kDeckGroupPadX;
const int innerRight = box.right() - kDeckGroupPadX;
// Caption row: text left, compact toggle right-anchored (r11 — the not-full-width home).
out.caption = Rect::ltrb(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 = Rect::ltrb(innerRight - segW, togTop, innerRight, togTop + kDeckToggleH);
const Rect seg0 = Rect::ltrb(seg1.x - segW, togTop, seg1.x, togTop + kDeckToggleH);
out.captionToggle = DeckToggleLayout{g.captionToggle.id, seg0, seg1};
// Caption text stops at the toggle: pull the right edge in (XYWH: shrink width).
out.caption.width = (seg0.x - kDeckToggleGap) - out.caption.x;
}
// 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::ltrb(x, cellTop, x + kDeckCellW, cellTop + kDeckCellH);
const int knobLeft = x + (kDeckCellW - kDeckKnobSize) / 2;
const int knobTop = cellTop + 4;
c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize);
const int labelTop = knobTop + kDeckKnobSize + 4;
c.label = Rect::ltrb(c.cell.x, 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 = Rect::ltrb(x, togTop, x + segW, togTop + kDeckToggleH);
const Rect seg1 = Rect::ltrb(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<DeckGroupDesc>& 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<DeckGroupDesc>& 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<DeckGroupDesc>& 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 = Rect::ltrb(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::instrument::ui
+133
View File
@@ -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 <vector>
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — the shared geometry idiom
namespace reasampler::instrument::ui {
// 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<int> 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<DeckCellLayout> cells;
DeckToggleLayout rowToggle; // id -1 when absent
};
struct DeckLayout {
std::vector<DeckGroupLayout> 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<DeckGroupDesc>& 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<DeckGroupDesc>& 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<DeckGroupDesc>& 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::instrument::ui
+160
View File
@@ -0,0 +1,160 @@
// param_slider.cpp — see param_slider.h. PURE control-surface geometry for the S12/S15/S16
// editor parameter panel. No host types; only the shared Rect + contains().
#include "core/instrument/ui/param_slider.h"
#include "core/util/clamp01.h"
#include <algorithm>
#include <cmath>
namespace reasampler::instrument::ui {
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24)
std::vector<ControlRow> layoutControls(const Rect& panel,
const std::vector<ControlDesc>& controls) {
std::vector<ControlRow> out;
if (controls.empty() || panel.width <= 0 || panel.height <= 0) return out;
out.reserve(controls.size());
// The label column is clamped so a narrow panel still leaves a control column.
const int labelW = (std::min)(kControlLabelWidth, (std::max)(0, panel.width / 2));
int rowTop = panel.y;
for (const ControlDesc& d : controls) {
ControlRow r;
r.id = d.id;
r.kind = d.kind;
const int rowBottom = rowTop + kControlRowHeight;
r.row = Rect::ltrb(panel.x, rowTop, panel.right(), rowBottom);
r.label = Rect::ltrb(panel.x, rowTop, panel.x + labelW, rowBottom);
r.control = Rect::ltrb(panel.x + labelW, rowTop, panel.right(), rowBottom);
out.push_back(r);
rowTop = rowBottom + kControlRowGap;
}
return out;
}
Rect toggleSegmentRect(const Rect& control, int seg) {
if (seg < 0 || seg >= kToggleSegments) return Rect{};
const int w = control.width;
if (w <= 0 || control.height <= 0) return Rect{};
const int segW = w / kToggleSegments;
const int left = control.x + seg * segW;
// The last segment absorbs the width remainder so the segments tile the whole control.
const int right = (seg == kToggleSegments - 1) ? control.right() : left + segW;
return Rect::ltrb(left, control.y, right, control.bottom());
}
int toggleSegmentHitTest(const Rect& control, int x, int y) {
if (!contains(control, x, y)) return -1;
for (int seg = 0; seg < kToggleSegments; ++seg) {
if (contains(toggleSegmentRect(control, seg), x, y)) return seg;
}
return -1;
}
Rect sliderTrackRect(const Rect& control) {
// Inset a half-handle at each end so the handle stays fully inside the control at value
// 0 and 1. The handle CENTER ranges across [track.x, track.right()].
const int half = kSliderHandleWidth / 2;
if (control.width <= kSliderHandleWidth || control.height <= 0) return Rect{};
return Rect::ltrb(control.x + half, control.y, control.right() - half, control.bottom());
}
Rect sliderHandleRect(const Rect& control, double value) {
const Rect track = sliderTrackRect(control);
if (track.width <= 0) return Rect{};
if (value < 0.0) value = 0.0;
if (value > 1.0) value = 1.0;
const int span = track.width; // handle-center movable span
const int centerX = track.x + static_cast<int>(value * span + 0.5);
const int half = kSliderHandleWidth / 2;
return Rect::ltrb(centerX - half, control.y, centerX - half + kSliderHandleWidth,
control.bottom());
}
double valueAtPoint(const Rect& control, int x) {
const Rect track = sliderTrackRect(control);
const int span = track.width;
if (span <= 0) return 0.0;
if (x <= track.x) return 0.0;
if (x >= track.right()) return 1.0;
return static_cast<double>(x - track.x) / static_cast<double>(span);
}
// --- Radial knob (Wave A FA4) ---------------------------------------------------------
namespace {
constexpr double kPi = 3.14159265358979323846;
// Normalize an angle in degrees to [0, 360).
double normDeg(double deg) {
deg = std::fmod(deg, 360.0);
if (deg < 0.0) deg += 360.0;
// Guard: fmod can return exactly 360.0 on some implementations due to floating-point
// rounding; fold it back to 0.
if (deg >= 360.0) deg -= 360.0;
return deg;
}
} // namespace
KnobGeometry computeKnob(const Rect& cell) {
if (cell.width <= 0 || cell.height <= 0) return KnobGeometry{};
KnobGeometry g;
g.centerX = (cell.x + cell.right()) / 2.0;
g.centerY = (cell.y + cell.bottom()) / 2.0;
g.radius = (std::min)(cell.width, cell.height) / 2.0;
return g;
}
bool knobHitTest(const KnobGeometry& knob, int x, int y) {
if (knob.radius <= 0.0) return false;
const double dx = x - knob.centerX;
const double dy = y - knob.centerY;
// Boundary exclusive: matches the module's half-open Rect convention.
return dx * dx + dy * dy < knob.radius * knob.radius;
}
double knobSweepDeg(const KnobArc& arc) {
const double sweep = normDeg(arc.endDeg) - normDeg(arc.startDeg);
// An end at-or-behind the start wraps clockwise past 12 o'clock; equal angles mean a
// full circle.
return sweep <= 0.0 ? sweep + 360.0 : sweep;
}
double knobValueAngleDeg(const KnobArc& arc, double value) {
return normDeg(normDeg(arc.startDeg) + knobSweepDeg(arc) * clamp01(value));
}
KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double value) {
// Clock angle -> screen direction: 0° points up (-y), 90° points right (+x).
const double rad = knobValueAngleDeg(arc, value) * kPi / 180.0;
return KnobPoint{knob.centerX + knob.radius * std::sin(rad),
knob.centerY - knob.radius * std::cos(rad)};
}
double knobDragValue(double startValue, int dyPixels, int dragRangePixels) {
const double start = clamp01(startValue);
if (dragRangePixels <= 0) return start;
// Screen y grows downward: an upward drag (negative dy) increases the value.
return clamp01(start - static_cast<double>(dyPixels) / dragRangePixels);
}
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y) {
for (const ControlRow& r : rows) {
if (r.kind == ControlKind::Toggle) {
if (contains(r.control, x, y)) return r.id;
} else if (r.kind == ControlKind::Knob) {
if (knobHitTest(computeKnob(r.control), x, y)) return r.id;
} else { // Slider — the interactive area is the track
if (contains(sliderTrackRect(r.control), x, y)) return r.id;
}
}
return -1;
}
} // namespace reasampler::instrument::ui
+180
View File
@@ -0,0 +1,180 @@
// param_slider.h — PURE control-surface layout + hit-test + value<->pixel mapping for the
// S12/S15/S16 editor parameter panel. NO VST3, NO REAPER, NO SWELL/LICE types at the
// boundary, and — deliberately — NO sampler_core / sample_map engine types either. The
// mirror of keyboard_strip / waveform_view / mode_switch: the fiddly slider-track and
// toggle-segment arithmetic lives here, unit-tested outside the DAW, while the editor shell
// draws each row (label + track/segments + handle) and routes clicks/drags into these
// functions, owning the control-id -> engine-param binding + the value DOMAIN mapping.
//
// WHY IT EXISTS (S12 + the S15/S16 control surfaces deferred here). The setup / Zones surface
// grows a stack of parameter controls: the S15 play-mode toggle (Gate|Trigger), the AHDSR
// amp-envelope sliders (attack/hold/decay/sustain/release), the Trigger %-length + fade
// controls, the S16 Varispeed|Preserve engine toggle, and the AD pitch-envelope
// enable/attack/decay/depth. They are three shapes — a two-segment TOGGLE, a horizontal
// SLIDER, and (Wave A FA4) a radial KNOB with a needle indicator and vertical-drag value
// mapping — laid out as a vertical stack of fixed-height rows. This module lays out that
// stack and maps a control's NORMALIZED value (0..1) to/from its handle pixel / needle
// angle; the shell converts each control's engine value (frames, seconds, a fraction, a
// signed semitone depth) to/from that 0..1 with its own domain knowledge (this module stays
// engine-free so it tests without the audio core).
//
// It reuses editor_geometry's Rect + contains() (one shared geometry idiom).
#pragma once
#include <vector>
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
namespace reasampler::instrument::ui {
// Fixed control-panel metrics, exposed so the shell and tests agree.
inline constexpr int kControlRowHeight = 22; // one control row (incl. its inter-row gap)
inline constexpr int kControlRowGap = 4; // vertical gap below each row
inline constexpr int kControlLabelWidth = 92; // the label column at the row's left
inline constexpr int kSliderHandleWidth = 8; // the draggable slider handle width (px)
inline constexpr int kToggleSegments = 2; // a toggle is always two segments
// A control is one of three shapes. Toggle = a two-segment selector (the active segment
// highlights); Slider = a horizontal track with a draggable handle over a 0..1 value;
// Knob = a radial dial with a needle indicator over a 0..1 value, dragged VERTICALLY
// (up = increase).
enum class ControlKind { Toggle, Slider, Knob };
// One control the shell places in the panel, in stack order. `id` is the shell's own control
// identifier (an int the shell casts from its ControlId enum) returned by the hit-test so the
// shell routes the interaction to the right engine param — this module never interprets it.
struct ControlDesc {
int id = 0;
ControlKind kind = ControlKind::Slider;
};
// The laid-out geometry of one control row: its full row rect plus the interactive sub-rect
// (the track for a Slider, the whole control area for a Toggle — the shell splits a Toggle
// into segments via toggleSegmentRect). `index` is the control's position in the stack.
struct ControlRow {
int id = 0;
ControlKind kind = ControlKind::Slider;
Rect row; // the full row (label column + control column)
Rect label; // the label column at the left
Rect control; // the control column to the right of the label (track / toggle area)
};
// Lay out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down. Each
// row is kControlRowHeight tall with kControlRowGap below it; the label column takes the left
// kControlLabelWidth (clamped so it never exceeds the panel), the control column the rest. A
// row whose top falls past the panel bottom is still returned (the shell clips at paint /
// suppresses it) so the stack geometry is deterministic regardless of panel height. An empty
// control list or a degenerate panel yields an empty vector. Pure.
std::vector<ControlRow> layoutControls(const Rect& panel,
const std::vector<ControlDesc>& controls);
// The rect of segment `seg` (0..kToggleSegments-1) within a toggle control's `control` rect,
// splitting it into kToggleSegments equal segments left-to-right (the last absorbs any width
// remainder, mirror of mode_switch's segment split). An out-of-range segment or a degenerate
// control rect yields an empty rect. Pure.
Rect toggleSegmentRect(const Rect& control, int seg);
// The toggle segment a point lands on within a toggle control's `control` rect, or -1 for a
// miss (outside the control area). Pure.
int toggleSegmentHitTest(const Rect& control, int x, int y);
// The slider track sub-rect inside a slider control's `control` rect: the control inset so the
// handle (kSliderHandleWidth) stays fully within the control at value 0 and 1 (a half-handle
// margin at each end). The handle CENTER ranges across [track.x, track.right()] as the value
// ranges [0,1]. The shell draws the track fill + handle here. A degenerate control yields an
// empty rect. Pure.
Rect sliderTrackRect(const Rect& control);
// The handle rect for a slider at normalized `value` (clamped to [0,1]) within `control`: a
// kSliderHandleWidth-wide bar centered at the value's position along sliderTrackRect. A
// degenerate control yields an empty rect. Pure — the inverse of valueAtPoint.
Rect sliderHandleRect(const Rect& control, double value);
// Map a point x to a normalized slider value [0,1] within `control` (the handle-center range).
// x at/left of the track start -> 0; at/right of the end -> 1; linear between. A degenerate
// track (zero movable span) -> 0. Pure — the inverse of sliderHandleRect's position map; the
// shell converts the returned 0..1 into its engine domain (frames/seconds/fraction/semitones).
double valueAtPoint(const Rect& control, int x);
// --- Radial knob (Wave A FA4) --------------------------------------------------------------
//
// Angle convention: DEGREES CLOCKWISE FROM 12 O'CLOCK, matching a clock face in screen
// coordinates (y grows downward): 0 = 12 o'clock (up), 90 = 3 o'clock (right), 180 = 6
// o'clock (down), 270 = 9 o'clock (left). The value arc sweeps CLOCKWISE from startDeg
// (value 0) to endDeg (value 1); an endDeg at-or-behind startDeg wraps +360, so equal
// angles mean a full 360° sweep.
//
// The DEFAULT arc is the conventional 7→5 o'clock layout: min at 7 o'clock (210°) sweeping
// clockwise 300° around to max at 5 o'clock (150°), leaving a symmetric 60° dead arc at the
// bottom. The 50% (midpoint) value lands at 12 o'clock (0°/360°) — straight up. The angles
// are PARAMETERS, not hardcoded — the shell sets the final sweep when the parallel layout
// spec lands.
inline constexpr double kKnobArcStartDeg = 210.0; // value 0 — 7 o'clock
inline constexpr double kKnobArcEndDeg = 150.0; // value 1 — 5 o'clock (clockwise wrap)
// Default vertical-drag sensitivity: pixels of upward drag for one full 0->1 sweep.
inline constexpr int kKnobDragRangePixels = 128;
// The configurable value arc of a knob. Defaults to the 7->5 o'clock reading above.
struct KnobArc {
double startDeg = kKnobArcStartDeg;
double endDeg = kKnobArcEndDeg;
};
// A knob's circle within its control cell: center + radius in pixel space (doubles so the
// shell rounds once, at draw time). radius == 0 marks a degenerate cell.
struct KnobGeometry {
double centerX = 0.0;
double centerY = 0.0;
double radius = 0.0;
};
// A pixel-space point (the needle endpoint the shell draws to).
struct KnobPoint {
double x = 0.0;
double y = 0.0;
};
// The knob circle inscribed in `cell`, centered, radius = half the smaller dimension. A
// degenerate cell yields radius 0. CONTRACT: the shell MUST pass `row.control` (the full
// control column) both when drawing and when hit-testing — `controlAtPoint` always uses
// `r.control` as the cell, so the draw cell and hit cell must be the same. If the shell
// wants to draw a smaller circle it must center it within `row.control` and accept that the
// hit area is the larger column-inscribed circle. Pure.
KnobGeometry computeKnob(const Rect& cell);
// True if (x, y) falls strictly inside the knob circle (boundary exclusive, matching the
// module's half-open Rect convention). A degenerate knob (radius <= 0) hits nothing. Pure.
bool knobHitTest(const KnobGeometry& knob, int x, int y);
// The clockwise sweep of `arc` in degrees, in (0, 360]: normalized end - start, wrapping
// +360 when the end is at-or-behind the start (default arc -> 300). Pure.
double knobSweepDeg(const KnobArc& arc);
// The needle angle for normalized `value` (clamped to [0,1]): startDeg at 0, endDeg at 1,
// linear between, returned normalized to [0, 360). Pure.
double knobValueAngleDeg(const KnobArc& arc, double value);
// The needle endpoint for normalized `value`: the point on the knob circle at the value's
// angle, from the center. The shell draws the needle from (centerX, centerY) to this point
// (or lerps toward the center for a shorter needle). Pure.
KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double value);
// Map a vertical drag onto a knob value: `startValue` is the value at drag start (clamped),
// `dyPixels` the pointer's y displacement in screen coordinates (down = positive). Dragging
// UP increases, DOWN decreases; `dragRangePixels` pixels of travel covers the full 0..1
// range. Result clamps to [0,1]; a non-positive drag range yields the clamped start value.
// Pure — the inverse map for the knob's drag interaction.
double knobDragValue(double startValue, int dyPixels,
int dragRangePixels = kKnobDragRangePixels);
// The control a point lands on, given the laid-out `rows`. Returns the control id (ControlDesc
// id) whose interactive area (a Slider's track, a Toggle's whole control area, a Knob's
// circle) contains the point, or -1 for a miss (a gap, the label column, or outside every
// row). The FIRST matching row wins (rows never overlap, so at most one matches). Pure — the
// shell's routing entry point: on a hit it reads the value (valueAtPoint /
// toggleSegmentHitTest / knobDragValue over the ensuing drag) and commits.
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y);
} // namespace reasampler::instrument::ui
+99
View File
@@ -0,0 +1,99 @@
// waveform_view.cpp — see waveform_view.h. Pure math; no host types.
#include "core/instrument/ui/waveform_view.h"
#include <algorithm>
#include <cstdlib> // std::abs (int overload)
namespace reasampler::instrument::ui {
namespace {
std::int64_t clampFrame(std::int64_t f, std::int64_t frameCount) {
if (f < 0) return 0;
if (f > frameCount) return frameCount;
return f;
}
} // namespace
int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame) {
const int w = std::max(0, area.width);
if (frameCount <= 0 || w <= 0) return area.x;
const std::int64_t f = clampFrame(frame, frameCount);
// Linear map: x = left + round(f * w / frameCount). Rounding keeps the marker line
// visually centered on its frame; the divide is exact rational (multiply first).
const std::int64_t num = f * static_cast<std::int64_t>(w) + frameCount / 2;
return area.x + static_cast<int>(num / frameCount);
}
std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x) {
const int w = std::max(0, area.width);
if (frameCount <= 0 || w <= 0) return 0;
if (x <= area.x) return 0;
if (x >= area.right()) return frameCount;
const std::int64_t dx = static_cast<std::int64_t>(x - area.x);
// Inverse of frameToX: frame = round(dx * frameCount / w). Round so click and marker draw
// agree at bin granularity.
const std::int64_t num = dx * frameCount + static_cast<std::int64_t>(w) / 2;
return clampFrame(num / static_cast<std::int64_t>(w), frameCount);
}
int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames,
int count, int x, int y) {
if (count <= 0 || frames == nullptr) return -1;
if (!contains(area, x, y)) return -1;
for (int i = 0; i < count; ++i) {
const int mx = frameToX(area, frameCount, frames[i]);
if (x >= mx - kMarkerGrabWidth && x <= mx + kMarkerGrabWidth) return i;
}
return -1;
}
std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame,
int dxPixels) {
const std::int64_t start = clampFrame(startFrame, frameCount);
if (dxPixels == 0) return start;
const int w = std::max(0, area.width);
if (frameCount <= 0 || w <= 0) return start; // no room to move
// Proportional shift, rounded to the nearest frame (same linear map as frameToX/xToFrame).
const std::int64_t magnitude =
(static_cast<std::int64_t>(std::abs(dxPixels)) * frameCount +
static_cast<std::int64_t>(w) / 2) /
static_cast<std::int64_t>(w);
const std::int64_t shift = dxPixels > 0 ? magnitude : -magnitude;
return clampFrame(start + shift, frameCount);
}
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
std::int64_t target) {
if (pcm == nullptr || frames < 2) return clampFrame(target, frames > 0 ? frames - 1 : 0);
// Clamp target into a valid sample index [0, frames).
std::int64_t t = target;
if (t < 0) t = 0;
if (t > frames - 1) t = frames - 1;
// A crossing lives at frame i (1 <= i < frames) when sign(pcm[i-1]) != sign(pcm[i]) OR
// pcm[i] == 0. isCrossing(i) tests exactly that. We fan out from t: at each distance d we
// probe t-d before t+d, so an equidistant tie resolves to the LOWER frame (deterministic).
auto isCrossing = [&](std::int64_t i) -> bool {
if (i < 1 || i >= frames) return false;
const AudioSample a = pcm[i - 1];
const AudioSample b = pcm[i];
if (b == 0.0f) return true; // a sample on zero is its own crossing
return (a < 0.0f) != (b < 0.0f); // sign change between i-1 and i
};
if (isCrossing(t)) return t;
for (std::int64_t d = 1; d < frames; ++d) {
const std::int64_t lo = t - d;
if (lo >= 1 && isCrossing(lo)) return lo; // lower side wins the tie
const std::int64_t hi = t + d;
if (hi < frames && isCrossing(hi)) return hi;
// Stop once both probes have run off both ends — no crossing anywhere.
if (lo < 1 && hi >= frames) break;
}
return t; // no sign change in the whole buffer -> keep the raw (clamped) target
}
} // namespace reasampler::instrument::ui
+85
View File
@@ -0,0 +1,85 @@
// waveform_view.h — PURE waveform/marker geometry + zero-crossing snap for the S11
// waveform surface. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror
// of keyboard_strip / editor_geometry: the fiddly frame<->pixel + marker hit-test + snap
// arithmetic lives here, unit-tested outside the DAW, while the editor shell
// (reasampler_editor.cpp) draws the envelope + markers and marshals mouse events into it.
//
// The surface maps a sample's full frame span [0, frameCount] linearly across a horizontal
// waveform rect. Draggable MARKERS mark frames of interest (S11: start point, loop start,
// loop end). The marker set is GENERIC — N named markers with drag + snap — deliberately
// not three hardcoded specials, so S15 (Trigger/Gate) can repurpose this same surface with a
// different marker set (start + %-length end + fades) without reworking the machinery.
//
// Interaction resolves through the pure DRAG-DELTA resolver here: the shell captures a grab
// on WM_LBUTTONDOWN (markerAtPoint identifies the grabbed marker), feeds each WM_MOUSEMOVE's
// pixel delta back through resolveDragFrame (which clamps + optionally zero-crossing-snaps),
// and commits on WM_LBUTTONUP. Live feedback is the shell re-drawing the in-flight frame.
//
// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom), so
// this header depends on editor_geometry.h rather than redefining a rectangle type. Audio
// is the peaks AudioSample float alias (the one house precedent — sampler_core / wav_trim do
// the same), so the zero-crossing helper takes the same mono PCM the shell already decoded.
#pragma once
#include <cstdint>
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
#include "core/audio/peaks.h" // AudioSample (float), the mono PCM the snap scans
namespace reasampler::instrument::ui {
using audio::AudioSample;
// The width (px) of a marker's grab region either side of its x line: a grab within this many
// pixels of a marker's drawn x is a grab OF that marker. Mirrors keyboard_strip's edge-grab
// idiom — wide enough to grab a 1px line comfortably, narrow enough that adjacent markers stay
// distinguishable.
inline constexpr int kMarkerGrabWidth = 5;
// The x pixel (inside `area`) of frame `frame` under the linear map: frame 0 -> area.x,
// frame frameCount -> area.right(). A frame is clamped to [0, frameCount] before mapping, so an
// out-of-range frame pins to an edge rather than escaping the rect. frameCount <= 0 or a
// zero-width area pins every frame to area.x (a degenerate, non-inverting result). Pure.
int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame);
// The frame a point x (inside `area`) maps to under the inverse linear map, clamped to
// [0, frameCount]. A point left of area.x yields 0; right of area.right() yields frameCount.
// frameCount <= 0 or a zero-width area yields 0. Pure — the inverse of frameToX (round-trips
// to the same frame at bin granularity).
std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x);
// Which marker (index into a caller-supplied parallel `frames` array, in draw order) a grab at
// (x, y) lands on, or -1 for a point off every marker (or off the waveform area). A marker is
// grabbed when x is within kMarkerGrabWidth of its drawn x AND y is inside `area`. First marker
// in order wins a tie where two markers overlap within the grab band (deterministic, mirroring
// keyboard_strip's first-match). `frames` is `count` frame indices; a null/empty array or
// count <= 0 yields -1. Pure — a raw pointer at the boundary (no host container), like
// keyboard_strip::zoneBarAtPoint.
int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames,
int count, int x, int y);
// Resolve a drag to a new frame. Given the frame the grabbed marker held at grab time
// (`startFrame`) and the horizontal pixel delta since grab (`dxPixels`), returns the frame the
// marker should now hold: startFrame shifted by round(dxPixels * frameCount / areaWidth),
// clamped to [0, frameCount]. A zero-width area or non-positive frameCount pins the result to
// the clamped startFrame (no motion). This is the single arithmetic behind every marker drag;
// the shell applies clamps BETWEEN markers (start <= loopEnd, loopStart <= loopEnd) after this
// per-marker resolve. Pure — rounding is to the nearest frame. Returns the clamped startFrame
// for dxPixels == 0.
std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame,
int dxPixels);
// The nearest zero-crossing frame to `target` in the mono PCM, for the loop/start snap (the
// S2 zero-crossing-aware requirement). A zero crossing is a frame index i (1 <= i < frames)
// where the sign of pcm[i-1] and pcm[i] differ (a sample exactly 0 counts as its own crossing
// — pcm[i] == 0 snaps to i). The search fans out symmetrically from the clamped target and
// returns the closest crossing frame; ties (equidistant crossings on both sides) resolve to
// the LOWER frame (deterministic). When the PCM has NO sign change anywhere (all one sign, or
// fewer than 2 frames), returns the clamped target unchanged (nothing to snap to — the caller
// keeps the raw frame). `target` is clamped to [0, frames) before searching. Pure — scans the
// decoded PCM the shell already holds; no host types, no file I/O.
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
std::int64_t target);
} // namespace reasampler::instrument::ui