Phase S FB1 (r11): Sample-view recomposition — knob deck + elastic full-width hero + curve popup w/ right-click delete + post-mixer master gain (ComponentState v8)

This commit is contained in:
2026-07-27 23:08:04 -04:00
parent 27b2661ef2
commit 43155cf320
17 changed files with 1942 additions and 324 deletions
+41
View File
@@ -0,0 +1,41 @@
// curve_popup.cpp — see curve_popup.h. Pure arithmetic; no LICE/VST3/REAPER includes.
#include "curve_popup.h"
#include <algorithm>
namespace reasampler::vst {
namespace {
int clampDim(int want, int lo, int hi, int windowDim) {
const int clamped = (std::max)(lo, (std::min)(hi, want));
return (std::min)(clamped, (std::max)(0, windowDim));
}
} // namespace
CurvePopupLayout computeCurvePopup(int w, int h) {
CurvePopupLayout out;
const int sheetW = clampDim((w * 60) / 100, kCurvePopupMinW, kCurvePopupMaxW, w);
const int sheetH = clampDim((h * 55) / 100, kCurvePopupMinH, kCurvePopupMaxH, h);
const int left = (w - sheetW) / 2;
const int top = (h - sheetH) / 2;
out.sheet = Rect{left, top, left + sheetW, top + sheetH};
const int titleBottom = out.sheet.top + kCurvePopupTitleH;
const int closeTop = out.sheet.top + (kCurvePopupTitleH - kCurvePopupCloseSize) / 2;
out.close = Rect{out.sheet.right - kCurvePopupPad - kCurvePopupCloseSize, closeTop,
out.sheet.right - kCurvePopupPad, closeTop + kCurvePopupCloseSize};
out.title = Rect{out.sheet.left + kCurvePopupPad, out.sheet.top,
out.close.left - kCurvePopupPad, titleBottom};
out.curveBox = Rect{out.sheet.left + kCurvePopupPad, titleBottom + 2,
out.sheet.right - kCurvePopupPad,
out.sheet.bottom - kCurvePopupPad};
return out;
}
bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y) {
return !contains(layout.sheet, x, y);
}
} // namespace reasampler::vst
+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 "editor_geometry.h" // Rect, contains
namespace reasampler::vst {
// Fixed popup metrics (spec r11), exposed so the shell and tests agree.
inline constexpr int kCurvePopupMinW = 360;
inline constexpr int kCurvePopupMaxW = 520;
inline constexpr int kCurvePopupMinH = 260;
inline constexpr int kCurvePopupMaxH = 380;
inline constexpr int kCurvePopupTitleH = 22;
inline constexpr int kCurvePopupCloseSize = 18;
inline constexpr int kCurvePopupPad = 8; // sheet inner padding (title inset + box margins)
struct CurvePopupLayout {
Rect sheet; // the bg/panel sheet, centered in the window
Rect title; // the caption text rect (left part of the title row)
Rect close; // the 18x18 Close (x) button, right-anchored in the title row
Rect curveBox; // the full-size curve editor BORDER rect (shell insets via curveBoxFromRect)
};
// The popup geometry for a (w x h) window: sheet width clamp(60% w, 360..520) and height
// clamp(55% h, 260..380) — each additionally capped at the window dimension so a degenerate
// window never yields an overhanging sheet — centered; title row + close button at the top;
// the curve box filling the remainder inside kCurvePopupPad margins. Pure.
CurvePopupLayout computeCurvePopup(int w, int h);
// True when (x, y) lands OUTSIDE the sheet (on the wash) — the click-outside dismissal test.
// The shell additionally gates on "no drag in flight" (spec). Pure.
bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y);
} // namespace reasampler::vst
+155
View File
@@ -0,0 +1,155 @@
// knob_deck.cpp — see knob_deck.h. Pure arithmetic; no LICE/VST3/REAPER includes.
#include "knob_deck.h"
#include <algorithm>
namespace reasampler::vst {
namespace {
// The knob-row width of a group: cells side by side (no inter-cell gap — the 48px cell
// already carries its own breathing room around the 28px knob), plus the optional row
// toggle after a kDeckToggleGap.
int knobRowWidth(const DeckGroupDesc& g) {
int w = static_cast<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.top + kDeckGroupPadY;
const int innerLeft = box.left + kDeckGroupPadX;
const int innerRight = box.right - kDeckGroupPadX;
// Caption row: text left, compact toggle right-anchored (r11 — the not-full-width home).
out.caption = Rect{innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH};
if (g.captionToggle.id >= 0) {
const int segW = g.captionToggle.segWidth;
const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2;
const Rect seg1{innerRight - segW, togTop, innerRight, togTop + kDeckToggleH};
const Rect seg0{seg1.left - segW, togTop, seg1.left, togTop + kDeckToggleH};
out.captionToggle = DeckToggleLayout{g.captionToggle.id, seg0, seg1};
out.caption.right = seg0.left - kDeckToggleGap; // caption text stops at the toggle
}
// Knob row: fixed cells left-to-right, then the optional row toggle.
const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap;
int x = innerLeft;
for (int id : g.cellIds) {
DeckCellLayout c;
c.id = id;
c.cell = Rect{x, cellTop, x + kDeckCellW, cellTop + kDeckCellH};
const int knobLeft = x + (kDeckCellW - kDeckKnobSize) / 2;
const int knobTop = cellTop + 4;
c.knob = Rect{knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize};
const int labelTop = knobTop + kDeckKnobSize + 4;
c.label = Rect{c.cell.left, labelTop, c.cell.right, labelTop + kDeckCellLabelH};
out.cells.push_back(c);
x += kDeckCellW;
}
if (g.rowToggle.id >= 0) {
if (!g.cellIds.empty()) x += kDeckToggleGap;
const int segW = g.rowToggle.segWidth;
const int togTop = cellTop + (kDeckCellH - kDeckToggleH) / 2;
const Rect seg0{x, togTop, x + segW, togTop + kDeckToggleH};
const Rect seg1{seg0.right, togTop, seg0.right + segW, togTop + kDeckToggleH};
out.rowToggle = DeckToggleLayout{g.rowToggle.id, seg0, seg1};
}
return out;
}
} // namespace
int deckGroupWidth(const DeckGroupDesc& g) {
return (std::max)(captionRowWidth(g), knobRowWidth(g)) + 2 * kDeckGroupPadX;
}
int deckRowCount(const std::vector<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{x, y, x + w, y + kDeckGroupH};
out.groups.push_back(layoutGroup(g, box));
x = box.right;
rowHasGroup = true;
}
out.height = out.rowCount * kDeckGroupH + (out.rowCount - 1) * kDeckRowGap;
return out;
}
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
for (const DeckGroupLayout& g : layout.groups) {
if (!contains(g.box, x, y)) continue;
if (g.captionToggle.id >= 0) {
if (contains(g.captionToggle.seg0, x, y))
return {DeckHitKind::CaptionToggle, g.captionToggle.id, 0};
if (contains(g.captionToggle.seg1, x, y))
return {DeckHitKind::CaptionToggle, g.captionToggle.id, 1};
}
if (g.rowToggle.id >= 0) {
if (contains(g.rowToggle.seg0, x, y))
return {DeckHitKind::RowToggle, g.rowToggle.id, 0};
if (contains(g.rowToggle.seg1, x, y))
return {DeckHitKind::RowToggle, g.rowToggle.id, 1};
}
for (const DeckCellLayout& c : g.cells) {
if (c.id >= 0 && contains(c.cell, x, y)) return {DeckHitKind::Knob, c.id, -1};
}
return {}; // inside the box but on fence/padding/blank — a miss (groups never overlap)
}
return {};
}
} // namespace reasampler::vst
+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 "editor_geometry.h" // Rect, contains — the shared geometry idiom
namespace reasampler::vst {
// Fixed deck metrics (spec r11), exposed so the shell and tests agree.
inline constexpr int kDeckCellW = 48; // one knob cell
inline constexpr int kDeckCellH = 58;
inline constexpr int kDeckKnobSize = 28; // knob diameter inside the cell
inline constexpr int kDeckCellLabelH = 12; // the Micro label band under the knob
inline constexpr int kDeckCaptionH = 20; // the group caption row
inline constexpr int kDeckToggleH = 18; // compact toggle segment height
inline constexpr int kDeckGroupPadX = 6; // group box horizontal inner padding
inline constexpr int kDeckGroupPadY = 4; // group box vertical inner padding
inline constexpr int kDeckCaptionGap = 2; // caption row -> knob row gap
inline constexpr int kDeckToggleGap = 4; // caption text -> toggle / cells -> row toggle gap
inline constexpr int kDeckGroupGap = 12; // gap between groups on a row
inline constexpr int kDeckRowGap = 8; // gap between wrapped deck rows
// One group box: padding + caption + gap + cell row + padding.
inline constexpr int kDeckGroupH =
kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap + kDeckCellH + kDeckGroupPadY;
// A two-segment compact toggle (always 2 segments — the Mono/Stereo grammar). id -1 = absent.
struct DeckToggleDesc {
int id = -1; // shell control id returned by the hit-test; -1 = no toggle
int segWidth = 44; // px per segment
};
// One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1
// is a RESERVED BLANK cell (geometry held, never hit — the AMP ENVELOPE Trigger face).
// `captionWidth` is the px the shell reserves for the caption text (this module does not
// measure text — the house constant-metrics pattern).
struct DeckGroupDesc {
int id = 0; // shell group id (opaque here)
int captionWidth = 60;
DeckToggleDesc captionToggle; // right-anchored in the caption row; id -1 = none
std::vector<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::vst
+51
View File
@@ -0,0 +1,51 @@
// master_gain.cpp — see master_gain.h. Pure math; no LICE/VST3/REAPER includes.
#include "master_gain.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <limits>
namespace reasampler::vst {
namespace {
double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
} // namespace
double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); }
double masterGainDbFromNorm(double norm) {
norm = clamp01(norm);
if (norm <= 0.0) return -std::numeric_limits<double>::infinity();
return kMasterGainMinDb + norm * (kMasterGainMaxDb - kMasterGainMinDb);
}
double masterGainNormFromDb(double db) {
if (!(db > kMasterGainMinDb)) return 0.0; // -inf, NaN, and the floor all read 0
return clamp01((db - kMasterGainMinDb) / (kMasterGainMaxDb - kMasterGainMinDb));
}
double masterGainLinearFromNorm(double norm) {
norm = clamp01(norm);
if (norm <= 0.0) return 0.0; // TRUE silence at the bottom — not an epsilon
return std::pow(10.0, masterGainDbFromNorm(norm) / 20.0);
}
double masterGainNormFromLinear(double linear) {
if (!std::isfinite(linear) || linear <= 0.0) return 0.0;
return masterGainNormFromDb(20.0 * std::log10(linear));
}
void formatMasterGainLabel(double norm, char* buf, std::size_t len) {
if (!buf || len == 0) return;
norm = clamp01(norm);
if (norm <= 0.0) {
std::snprintf(buf, len, "-inf");
return;
}
const double db = masterGainDbFromNorm(norm);
std::snprintf(buf, len, "%+.1fdB", db);
}
} // namespace reasampler::vst
+54
View File
@@ -0,0 +1,54 @@
// master_gain.h — PURE dB<->linear<->knob-taper math for the FB1 post-mixer master gain.
// NO VST3, NO REAPER, NO SWELL/LICE types. The mirror of trigger_seam: one tiny module owns
// the ONE formula both sides of a seam share — here the editor's Gain knob (normalized 0..1)
// and the processor's stored/applied linear gain — so the drawn needle, the persisted value,
// and the audio-thread multiply can never drift.
//
// THE CONTROL (Daniel, FB1). A post-mixer master gain, range -inf .. +24 dB, dB-scaled taper
// with -inf at the BOTTOM of the knob: normalized 0 maps to TRUE ZERO linear gain (silence,
// not a tiny epsilon), and the remaining travel maps linearly in dB from kMasterGainMinDb
// (the finite taper floor) up to kMasterGainMaxDb. Unity (0 dB) sits at norm
// kMasterGainMinDb/(kMasterGainMinDb - kMasterGainMaxDb) ~= 0.714 — most of the throw is
// usable trim, the last stretch is boost. The PERSISTED value is the LINEAR gain (a plain
// finite double, 0 = silence — no -inf on the wire); the taper is a UI-side view of it.
//
// RT DISCIPLINE: the processor applies the linear gain as one multiply over the summed
// output — these functions run on the UI/state threads only.
#pragma once
#include <cstddef>
namespace reasampler::vst {
// The dB taper endpoints. norm 0 is -inf (true zero); norm just above 0 starts at the
// finite floor kMasterGainMinDb and sweeps linearly in dB to kMasterGainMaxDb at norm 1.
inline constexpr double kMasterGainMinDb = -60.0;
inline constexpr double kMasterGainMaxDb = 24.0;
// The largest linear gain the control can produce (kMasterGainMaxDb as a ratio, ~15.849).
double masterGainMaxLinear();
// Knob taper: normalized [0,1] -> dB. norm <= 0 -> -infinity; else the linear-in-dB sweep
// [kMasterGainMinDb, kMasterGainMaxDb]. norm is clamped to [0,1]. Pure.
double masterGainDbFromNorm(double norm);
// Inverse taper: dB -> normalized [0,1]. -infinity (or any dB at/below kMasterGainMinDb)
// maps to the bottom of the finite sweep (0 for -inf, else the clamped floor); +24 -> 1. Pure.
double masterGainNormFromDb(double db);
// Knob taper composed with dB->ratio: normalized [0,1] -> LINEAR gain. norm 0 -> exactly
// 0.0 (true silence); norm 1 -> masterGainMaxLinear(). Pure.
double masterGainLinearFromNorm(double norm);
// Inverse: LINEAR gain -> normalized [0,1]. linear <= 0 -> 0 (the -inf bottom); a linear at
// or below the kMasterGainMinDb floor also reads ~0+ (the taper's finite bottom); unity ->
// ~0.714; masterGainMaxLinear() -> 1. Out-of-range/non-finite input clamps. Pure.
double masterGainNormFromLinear(double linear);
// The knob's hover/drag value label for a normalized value: "-inf" at the bottom, else a
// signed one-decimal dB string ("-12.0dB", "+0.0dB", "+2.4dB"). Writes at most `len` bytes
// including the terminator. Pure.
void formatMasterGainLabel(double norm, char* buf, std::size_t len);
} // namespace reasampler::vst
File diff suppressed because it is too large Load Diff
+71 -6
View File
@@ -33,7 +33,8 @@
#include "editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules)
#include "envelope_edit.h" // EnvClampBounds / NodeHit (S-VIEW-3 envelope node hit-test/edit)
#include "envelope_overlay.h" // AmpEnvelope / EnvNode (S-VIEW-3 envelope overlay draw seam)
#include "param_slider.h" // ControlRow (the S12/S15/S16 control-surface geometry)
#include "knob_deck.h" // DeckGroupDesc / DeckLayout (r11 Sample-face knob deck, FB1)
#include "param_slider.h" // ControlRow + the FA4 radial-knob primitive (S12/S15/S16 + r11)
#include "peaks.h" // Envelope (the cached peak thumbnail)
#include "sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot)
#include "velocity_curve.h" // VelocityCurve (S-VIEW-10 transfer-curve editor state)
@@ -80,9 +81,11 @@ private:
// marker on the S11 waveform surface (which marker is in waveMarker_); kEnvNode is a
// draggable envelope breakpoint on the Sample-view hero overlay (S-VIEW-3, which node in
// envNode_); kCurveNode is a draggable velocity-curve control point in the S-VIEW-10
// transfer-curve editor (which point in curvePointIndex_).
// transfer-curve editor (which point in curvePointIndex_); kDeckKnob is a GRAB-ANCHORED
// vertical radial-knob drag on the r11 Sample-face deck/cluster (which control in
// dragParamId_; the value at grab in dragKnobStartValue_ — no jump on grab, FA4).
enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker,
kScrollThumb, kParamSlider, kEnvNode, kCurveNode };
kScrollThumb, kParamSlider, kEnvNode, kCurveNode, kDeckKnob };
// The parameter controls on the setup surface (S12 AHDSR + the S15/S16 control surfaces).
// The int value is the ControlDesc id the pure param_slider hit-test returns; the shell
@@ -103,6 +106,12 @@ private:
kPitchEnvDecay, // AD pitch decay (S16)
kPitchEnvDepth, // AD pitch depth in +/- semitones (S16)
kKeyTrack, // S-VIEW-6 key-tracking 0..200% (lives on PerformanceZone, not ZonePlaySeconds)
// r11 deck-only controls (FB1): processor-side per-instance params, NOT zone params —
// routed to the processor setters, never through applyZoneControl / the map.
kVoiceCount, // Phase S polyphony bound (1..32) — a stepped knob in the VOICE group
kVoiceMode, // Poly | Mono caption toggle (VOICE group)
kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono)
kMasterGain, // FB1 post-mixer master gain knob (-inf..+24 dB taper, MASTER group)
kCount
};
@@ -134,8 +143,11 @@ private:
kPreview, // the Sample-view preview-trigger button
kAddZone, // the "+ Add Zone" button
kDeleteZone, // the "Delete" zone button
kControl, // a param-panel control row (index = ControlDesc id)
kControl, // a param-panel control row / deck element (index = control id)
kCurveNode, // a velocity-curve control point (index = point index, S-VIEW-10)
kVelKnob, // the cluster preview-velocity radial knob (r11)
kCurveButton, // the cluster mini curve-preview button (r11 — opens the popup)
kPopupClose, // the curve popup's Close (x) button (r11)
};
struct HoverTarget {
HoverKind kind = HoverKind::kNone;
@@ -146,11 +158,19 @@ private:
#ifdef _WIN32
void paint(HDC hdc);
void paintSample(LICE_IBitmap* bmp, int w, int h); // S-VIEW-2 home face
void paintSample(LICE_IBitmap* bmp, int w, int h); // S-VIEW-2/r11 home face
void paintBrowse(LICE_IBitmap* bmp, int w, int h); // S-VIEW-5 modal picker overlay
void paintZone(LICE_IBitmap* bmp, int w, int h); // S-VIEW-8 zone surface
void paintEmptyState(LICE_IBitmap* bmp, const Rect& area);
void paintControls(LICE_IBitmap* bmp, const Rect& panel, const PerformanceZone& zone); // S12/S15/S16 + keyTrack
void paintControls(LICE_IBitmap* bmp, const Rect& panel, const PerformanceZone& zone); // S12/S15/S16 + keyTrack (Zone surface; the Sample face uses the r11 knob deck)
// --- r11 Sample-face recomposition (FB1) ---------------------------------------
// The knob deck: the fenced task groups (AMP ENVELOPE / PITCH / PITCH ENV / VOICE /
// MASTER) drawn through the L1 kit — group fence + caption + compact caption toggles +
// radial knobs (param_slider's FA4 primitive) with label<->value swap on hover/drag.
void paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, const PerformanceZone& zone);
// The centered curve-popup sheet (wash + title + close + full-size curve editor).
void paintCurvePopup(LICE_IBitmap* bmp, int w, int h);
// Trace the S-VIEW-3 amp-envelope overlay + its draggable node handles over `waveArea` for
// `zone`'s play params, at the sample's wall-clock duration. Shared by the Sample hero band.
void paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, const PerformanceZone& zone,
@@ -170,6 +190,11 @@ private:
void onMouseDown(int x, int y);
void onMouseMove(int x, int y);
void onMouseUp(int x, int y);
// r11: right-click — the curve popup's PRIMARY node-delete affordance (issue 3c). Only
// acts while the popup is open; a right-click on a popup curve node deletes it through
// the same commit path as Alt-click (deletePoint's endpoint guard makes endpoint
// right-clicks a safe no-op). Everything else ignores right-clicks.
void onMouseRDown(int x, int y);
// Route a click at (x,y) into the param control panel `panel` editing map_.zones[zoneIndex]:
// a toggle segment commits immediately, a slider grab starts a live param-drag (kParamSlider),
@@ -337,6 +362,37 @@ private:
// needs a concrete zone to write. Returns -1 if selectedId_ is empty.
int ensureSampleZone();
// --- r11 knob-deck plumbing (FB1) ---------------------------------------------
//
// The deck is the r11 replacement for the Sample face's slider control strip: the pure
// knob_deck module lays out the fenced groups, param_slider's FA4 primitive owns the
// value<->needle map, and these members own the control-id <-> value binding (the same
// division of labor paintControls/applyControl use for the Zone surface's sliders).
// The deck group descriptors for the current mode: AMP ENVELOPE (Gate: A/H/D/S/R;
// Trigger: Fade In / Length % / Fade Out + two RESERVED blanks so a mode flip never
// reflows the neighbours) / PITCH (Key Track) / PITCH ENV (P.Attack/P.Decay/P.Depth) /
// VOICE (Voices knob + Poly|Mono caption toggle + Retrig|Legato row toggle) / MASTER
// (the FB1 post-mixer Gain knob).
std::vector<DeckGroupDesc> deckGroupDescs(const ZonePlaySeconds& play) const;
// The normalized [0,1] value a deck knob shows for `zone` — zone params route through
// controlValue/keyTrack; the processor-side ids (voice count, master gain, and the
// cluster's preview velocity via the -2 sentinel) read the processor's live value, so
// the knob and its storage are two views on one model (re-read each paint).
double deckControlNorm(int id, const PerformanceZone& zone) const;
// Apply a deck-knob value: zone params write map_.zones[zoneIndex] (live-drag semantics,
// commit on release); processor params (voice count / master gain / preview velocity)
// write through the processor setters immediately (transient — no map edit, no reload).
// zoneIndex is ignored for processor-side ids.
void applyDeckKnob(int zoneIndex, int id, double norm);
// The knob's live value label (shown in place of the name label during hover/drag):
// seconds ("0.123s"), percents ("85%"), source frames ("8820f"), signed semitones
// ("+3.5st"), a voice count ("16"), or the master-gain dB ("-inf"/"+2.4dB").
std::string deckValueLabel(int id, const PerformanceZone& zone) const;
ReaSamplerProcessor* processor_ = nullptr;
// --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) ---
@@ -444,6 +500,15 @@ private:
Rect dragCurveRect_{};
int dragCurveZone_ = -1;
// r11 deck-knob drag (FB1): the control's normalized value AT GRAB — knobDragValue maps
// the vertical pixel delta from this anchor, so a grab never jumps the value (FA4).
double dragKnobStartValue_ = 0.0;
// r11 curve popup (FB1): open flag — editor-local, never persisted. The popup edits the
// picked capture's one-zone site (effectiveSampleZone / ensureSampleZone), re-resolved
// each paint so a sync-tick refresh mid-open stays coherent.
bool curvePopupOpen_ = false;
// --- Peak-thumbnail cache (mirror of bank_panel; id -> envelope at a bin width) ------
// Keyed by "id|binCount" so a resize recomputes at the new width. Cleared on refresh so
// a bank edit (a re-captured or deleted sample) does not show a stale thumbnail.
+34
View File
@@ -22,6 +22,7 @@
#include "bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution)
#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey (shared wire contract)
#include "master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp)
#include "reasampler_editor.h"
#include "reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there)
#include "sample_map.h" // selectSample, resolvePerformance, buildZonedKeymap, state (de)ser
@@ -224,6 +225,10 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
voiceMode_ = cs.voiceMode;
monoTrigger_ = cs.monoTrigger;
}
// FB1: restore the post-mixer master gain (v8; older blobs lift to unity in
// deserializeComponentState — pre-FB1 output). One atomic store; the audio thread picks
// it up at the next block start.
setMasterGainLinear(cs.masterGainLinear);
// Rebuild from the restored state (off-thread — setState is a load-time call).
reloadFromBank();
return kResultOk;
@@ -252,6 +257,7 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
state_out.voiceMode = voiceMode_;
state_out.monoTrigger = monoTrigger_;
}
state_out.masterGainLinear = masterGainLinear(); // FB1: persist the post-mixer gain (v8)
const std::vector<std::uint8_t> bytes = serializeComponentState(state_out);
if (!bytes.empty()) {
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
@@ -351,6 +357,16 @@ void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) {
rebuildVoiceEngine();
}
void ReaSamplerProcessor::setMasterGainLinear(double linear) {
// Clamp to the control's legal span (the master_gain taper: 0 = -inf/silence, cap =
// +24 dB). One relaxed atomic store — the audio thread reads it at the next block start;
// no rebuild, no lock (a post-sum output trim is not a keymap fact).
if (!(linear >= 0.0)) linear = 0.0; // also catches NaN
const double maxLin = masterGainMaxLinear();
if (linear > maxLin) linear = maxLin;
masterGain_.store(static_cast<float>(linear), std::memory_order_relaxed);
}
void ReaSamplerProcessor::previewNoteOn(int note) {
if (note < 0) note = 0;
if (note > 127) note = 127;
@@ -877,6 +893,16 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
drain->engine.render(ch0, ch1, static_cast<std::size_t>(frames));
drain->preview.render(ch0, ch1, static_cast<std::size_t>(frames));
}
// FB1 post-mixer master gain: ONE relaxed load per block, applied AFTER the voice sum
// (engine + drain + preview) and BEFORE the extra-channel mirror + peak, so the mirror
// and the level indicator both see the actual output. A cheap multiply — no per-voice
// cost, no alloc, no lock (RT discipline).
{
const float g = masterGain_.load(std::memory_order_relaxed);
if (g != 1.f) {
for (int32 i = 0; i < frames; ++i) { ch0[i] *= g; ch1[i] *= g; }
}
}
// Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2).
for (int32 ch = 2; ch < out.numChannels; ++ch) {
if (float* buf = out.channelBuffers32[ch]) {
@@ -904,6 +930,14 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
drain->engine.render(ch0, static_cast<std::size_t>(frames));
drain->preview.render(ch0, static_cast<std::size_t>(frames));
}
// FB1 post-mixer master gain (mono path) — same contract as the stereo branch above:
// post-sum, pre-peak/replicate, one relaxed load, RT-safe.
{
const float g = masterGain_.load(std::memory_order_relaxed);
if (g != 1.f) {
for (int32 i = 0; i < frames; ++i) ch0[i] *= g;
}
}
float peak = 0.f;
for (int32 i = 0; i < frames; ++i) {
const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i];
+16
View File
@@ -210,6 +210,17 @@ public:
MonoTrigger monoTrigger();
void setMonoTrigger(MonoTrigger trigger);
// --- FB1 post-mixer master gain (per-instance, persisted in component state v8) ---------
// LINEAR gain in [0, masterGainMaxLinear()] (0.0 = -inf/true silence, 1.0 = unity, cap =
// +24 dB; the pure master_gain module owns the dB knob taper). Held in an atomic so the
// audio thread applies it with ONE relaxed load per block as a post-sum multiply over the
// rendered output (engine + drain + preview) — no lock, no rebuild, no per-voice cost.
// Written by the editor's Gain knob (UI thread) and setState; read by getState + process().
double masterGainLinear() const {
return static_cast<double>(masterGain_.load(std::memory_order_relaxed));
}
void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()]
// Fire a one-shot PREVIEW note-on / note-off through the live instrument's PREVIEW CARD
// (S-VIEW-4; Phase S isolation) — a dedicated single voice structurally OUTSIDE the MIDI
// pool, so a full pool never drops a preview and a preview never steals a playing voice.
@@ -355,6 +366,11 @@ private:
VoiceMode voiceMode_ = VoiceMode::Poly;
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
// FB1 post-mixer master gain (LINEAR; persisted in component state v8). A lock-free
// atomic — the ONE voice-param the audio thread reads directly (a single relaxed load
// per block, applied as a post-sum multiply). Default unity = pre-FB1 output.
std::atomic<float> masterGain_{1.0f};
// --- S-VIEW-4 preview-trigger mailbox (off-thread -> audio thread, lock-free) ---------
// The editor's preview-trigger button posts a note-on/off request from the UI thread; process()
// drains it at block start and drives the live instrument's PREVIEW CARD (Phase S — never the
+29 -3
View File
@@ -5,9 +5,12 @@
#include <algorithm> // std::min
#include <cassert> // assert
#include <cmath> // std::isfinite (v8 master-gain validation)
#include <cstring> // std::memcpy
#include <utility> // std::move
#include "master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap
namespace reasampler {
namespace {
@@ -615,6 +618,17 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
out.push_back(static_cast<std::uint8_t>(vc));
out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0);
out.push_back(state.monoTrigger == MonoTrigger::Legato ? 1 : 0);
// v8 envelope addition (FB1 master gain): the post-mixer LINEAR gain as an IEEE-754 double
// (bit-cast to u64 LE), following the voice bytes so a v7 blob is a strict prefix up to
// here (see the v7 lift). The WRITER never emits an out-of-range value: non-finite or
// negative falls back to unity; above the +24 dB cap clamps to the cap.
{
double g = state.masterGainLinear;
const double maxLin = vst::masterGainMaxLinear();
if (!std::isfinite(g) || g < 0.0) g = 1.0;
if (g > maxLin) g = maxLin;
putU64le(out, doubleToBits(g));
}
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
// unlike the v1 selection blob where the id ran to end-of-stream).
putU32le(out, static_cast<std::uint32_t>(state.selectionId.size()));
@@ -691,11 +705,12 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
return out; // previewVelocity stays at the mid default (pre-S-VIEW-4)
}
if (version != kComponentStateVersion &&
version != kSelectionZonesModeMarkerVelVoiceV7Version &&
version != kSelectionZonesModeMarkerVelV6Version) {
return out; // unknown -> empty
}
// v6/v7 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker,
// v6/v7/v8 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker,
// then the 1-byte preview velocity, precede the v3 body. A non-{0,1} mode byte is treated
// as mono (conservative default) rather than rejected — a corrupt mode never silences the
// instance.
@@ -711,9 +726,9 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
out.previewVelocity = (previewVel >= 1 && previewVel <= 127)
? previewVel
: kPreviewVelocityDefault;
// v7 (Phase S): the three voice-system bytes. A v6 blob (pre-Phase-S) skips them — the
// v7+ (Phase S): the three voice-system bytes. A v6 blob (pre-Phase-S) skips them — the
// construction defaults {16, Poly, Retrigger} hold, reproducing pre-Phase-S behavior.
if (version == kComponentStateVersion) {
if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) {
const std::uint8_t vc = r.u8();
const std::uint8_t vm = r.u8();
const std::uint8_t mt = r.u8();
@@ -726,6 +741,17 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly;
out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
}
// v8 (FB1): the master-gain LINEAR double. A v7 blob (pre-FB1) skips it — the construction
// default (unity) holds, reproducing pre-FB1 output exactly. A non-finite, negative, or
// above-cap value (a corrupt blob) falls back to unity rather than silencing/blasting.
if (version == kComponentStateVersion) {
const double g = bitsToDouble(asU64(r.i64()));
if (!r.ok) return out; // truncated inside the gain double -> empty (unity holds)
out.masterGainLinear =
(std::isfinite(g) && g >= 0.0 && g <= vst::masterGainMaxLinear() * (1.0 + 1e-9))
? g
: 1.0;
}
const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
+29 -14
View File
@@ -447,22 +447,26 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
// state), never auto-playing sample #1.
//
// Format (envelope v7): 4-byte LE version tag (== 7), then a 1-byte channel-mode field (0 = mono,
// Format (envelope v8): 4-byte LE version tag (== 8), then a 1-byte channel-mode field (0 = mono,
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a
// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system
// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono
// trigger (0 = Retrigger, 1 = Legato), then a 4-byte LE selection-id length + id bytes, then the
// CURRENT zones payload (identical to serializePerformance's body — its own self-describing
// version, see the ZONES-PAYLOAD block). The three voice bytes are the ONLY envelope-v7 addition
// over envelope-v6 — the envelope grew fields, the zones payload is untouched (a PARALLEL track
// owns zone-record extension under its own versioning; the two version numbers are independent
// axes — do NOT bump the zones-payload version for an envelope field). An out-of-range voice
// byte (a corrupt blob) falls back to the field's default rather than silencing the instance
// (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to channelMode =
// MONO, lastConsumedAssignGeneration = 0, previewVelocity = kPreviewVelocityDefault, and the
// Phase-S voice defaults {16 voices, Poly, Retrigger} — which reproduce pre-Phase-S behavior
// exactly — preserving current behavior for already-saved instances):
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones} direct.
// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754
// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then a 4-byte LE
// selection-id length + id bytes, then the CURRENT zones payload (identical to
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
// The master-gain double is the ONLY envelope-v8 addition over v7 — the envelope grew a field,
// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own
// versioning; the two version numbers are independent axes — do NOT bump the zones-payload
// version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range
// master-gain double (a corrupt blob) falls back to the field's default rather than silencing
// the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to
// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity =
// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, and unity
// master gain — which reproduce pre-v8 behavior exactly — preserving current behavior for
// already-saved instances):
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones} direct.
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain).
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults).
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity).
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker).
@@ -497,9 +501,20 @@ struct ComponentState {
int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato
// FB1 (Wave B) post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity;
// up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output
// trim applied by process() AFTER the voice sum (engine + drain + preview) — never per
// voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically,
// so an older blob lifting to 1.0 plays exactly as it did.
double masterGainLinear = 1.0;
};
inline constexpr std::uint32_t kComponentStateVersion = 7;
inline constexpr std::uint32_t kComponentStateVersion = 8;
// The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker +
// preview velocity + voice system, no master gain). Retained so deserializeComponentState can
// lift a v7 blob to unity master gain.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7;
// The pre-Phase-S combined-state version (selection + zones + channel mode + consumed marker +
// preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a