Merge pS-fb1-sampleview: FB1 (r11) Sample-view recompose — knob deck, full-width hero, curve popup, post-mixer master gain (v8)

This commit is contained in:
2026-07-27 23:38:30 -04:00
17 changed files with 2020 additions and 329 deletions
+46 -2
View File
@@ -754,7 +754,9 @@ target_link_libraries(embed_strip PUBLIC editor_geometry)
# calls these; the process callback stays allocation-free. # calls these; the process callback stays allocation-free.
add_library(sample_map STATIC src/vst/sample_map.cpp) add_library(sample_map STATIC src/vst/sample_map.cpp)
target_include_directories(sample_map PUBLIC src/vst src) target_include_directories(sample_map PUBLIC src/vst src)
target_link_libraries(sample_map PUBLIC bank_book wav_trim sampler_core) # master_gain: the v8 component-state master-gain field validates against the pure taper's
# linear cap at the (de)serialization boundary (one cap, shared with the knob + the processor).
target_link_libraries(sample_map PUBLIC bank_book wav_trim sampler_core master_gain)
# capture_browser (Phase S10) — PURE card-grid + bank-filter-tab layout + hit-test for the # capture_browser (Phase S10) — PURE card-grid + bank-filter-tab layout + hit-test for the
# capture-first editor's default face. The mirror of mode_switch/editor_geometry: the fiddly # capture-first editor's default face. The mirror of mode_switch/editor_geometry: the fiddly
@@ -843,6 +845,29 @@ add_library(envelope_edit STATIC src/vst/envelope_edit.cpp)
target_include_directories(envelope_edit PUBLIC src/vst) target_include_directories(envelope_edit PUBLIC src/vst)
target_link_libraries(envelope_edit PUBLIC envelope_overlay) target_link_libraries(envelope_edit PUBLIC envelope_overlay)
# knob_deck (Wave B FB1, r11) — PURE knob-deck layout + hit-test for the recomposed Sample face:
# fenced groups (caption row + compact caption toggles + fixed 48x58 knob cells + optional row
# toggle), deterministic whole-group wrap, point -> control-id hit-test. The mirror of
# action_bar / param_slider; links editor_geometry for the shared Rect. Engine-free — cells and
# toggles carry opaque shell-owned control ids. NEITHER SDK.
add_library(knob_deck STATIC src/vst/knob_deck.cpp)
target_include_directories(knob_deck PUBLIC src/vst)
target_link_libraries(knob_deck PUBLIC editor_geometry)
# curve_popup (Wave B FB1, r11) — PURE centered-sheet geometry for the velocity-curve popup
# editor: size clamps (60%/55% of window, 360..520 x 260..380), title row + close button, the
# curve-box border rect, and the outside-sheet dismissal test. The mirror of overflow_menu;
# links editor_geometry for the shared Rect. NEITHER SDK.
add_library(curve_popup STATIC src/vst/curve_popup.cpp)
target_include_directories(curve_popup PUBLIC src/vst)
target_link_libraries(curve_popup PUBLIC editor_geometry)
# master_gain (Wave B FB1) — PURE dB<->linear<->knob-taper math for the post-mixer master gain
# (-inf..+24 dB; norm 0 = TRUE zero linear). One formula shared by the editor's Gain knob, the
# v8 component-state wire cap, and the processor's applied gain. Standard library only. NEITHER SDK.
add_library(master_gain STATIC src/vst/master_gain.cpp)
target_include_directories(master_gain PUBLIC src/vst)
add_executable(editor_geometry_tests tests/test_editor_geometry.cpp) add_executable(editor_geometry_tests tests/test_editor_geometry.cpp)
target_link_libraries(editor_geometry_tests PRIVATE editor_geometry) target_link_libraries(editor_geometry_tests PRIVATE editor_geometry)
add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests) add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests)
@@ -917,6 +942,21 @@ add_executable(envelope_edit_tests tests/test_envelope_edit.cpp)
target_link_libraries(envelope_edit_tests PRIVATE envelope_edit) target_link_libraries(envelope_edit_tests PRIVATE envelope_edit)
add_test(NAME envelope_edit_tests COMMAND envelope_edit_tests) add_test(NAME envelope_edit_tests COMMAND envelope_edit_tests)
# knob_deck (Wave B FB1): the pure r11 deck layout/wrap/hit-test. Links ONLY knob_deck — NEITHER SDK.
add_executable(knob_deck_tests tests/test_knob_deck.cpp)
target_link_libraries(knob_deck_tests PRIVATE knob_deck)
add_test(NAME knob_deck_tests COMMAND knob_deck_tests)
# curve_popup (Wave B FB1): the pure r11 popup-sheet geometry at the size clamps. NEITHER SDK.
add_executable(curve_popup_tests tests/test_curve_popup.cpp)
target_link_libraries(curve_popup_tests PRIVATE curve_popup)
add_test(NAME curve_popup_tests COMMAND curve_popup_tests)
# master_gain (Wave B FB1): the pure dB<->linear<->knob taper. NEITHER SDK.
add_executable(master_gain_tests tests/test_master_gain.cpp)
target_link_libraries(master_gain_tests PRIVATE master_gain)
add_test(NAME master_gain_tests COMMAND master_gain_tests)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1123,10 +1163,14 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
# and the node hit-test + pixel-delta -> clamped-param inverse map the Sample-view envelope # and the node hit-test + pixel-delta -> clamped-param inverse map the Sample-view envelope
# overlay draws + drags against; envelope_edit links envelope_overlay transitively (shared # overlay draws + drags against; envelope_edit links envelope_overlay transitively (shared
# node vocabulary + timeToX/levelToY). Both engine-free, DAW-verified in the shell. # node vocabulary + timeToX/levelToY). Both engine-free, DAW-verified in the shell.
# knob_deck + curve_popup + master_gain (Wave B FB1, r11): the pure deck layout/hit-test,
# the curve-popup sheet geometry, and the master-gain taper the recomposed Sample face
# draws + routes against (master_gain also rides in via sample_map for the v8 wire cap).
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal
sample_map capture_paths embed_strip app_version capture_browser keyboard_strip sample_map capture_paths embed_strip app_version capture_browser keyboard_strip
waveform_view bank_sync browser_scroll note_entry param_slider waveform_view bank_sync browser_scroll note_entry param_slider
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit) theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
knob_deck curve_popup master_gain)
# SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge;
# WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC.
target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC}) target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC})
+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
+58
View File
@@ -0,0 +1,58 @@
// 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 or below kMasterGainMinDb,
// including below-floor values like -80 dB) maps to norm 0 (the -inf bottom detent) — the
// finite sweep only covers the range above kMasterGainMinDb; everything at or below it collapses
// to the same true-zero bottom. +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 (e.g. 0.001 = -60 dB, or anything below) also maps to 0
// — the floor IS the -inf detent; values between true-zero and the floor cannot be represented
// on the knob and collapse to the 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
+74 -7
View File
@@ -33,7 +33,8 @@
#include "editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules) #include "editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules)
#include "envelope_edit.h" // EnvClampBounds / NodeHit (S-VIEW-3 envelope node hit-test/edit) #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 "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 "peaks.h" // Envelope (the cached peak thumbnail)
#include "sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot) #include "sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot)
#include "velocity_curve.h" // VelocityCurve (S-VIEW-10 transfer-curve editor state) #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 // 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 // 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 // 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, 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 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 // 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) kPitchEnvDecay, // AD pitch decay (S16)
kPitchEnvDepth, // AD pitch depth in +/- semitones (S16) kPitchEnvDepth, // AD pitch depth in +/- semitones (S16)
kKeyTrack, // S-VIEW-6 key-tracking 0..200% (lives on PerformanceZone, not ZonePlaySeconds) 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 kCount
}; };
@@ -134,8 +143,11 @@ private:
kPreview, // the Sample-view preview-trigger button kPreview, // the Sample-view preview-trigger button
kAddZone, // the "+ Add Zone" button kAddZone, // the "+ Add Zone" button
kDeleteZone, // the "Delete" 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) 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 { struct HoverTarget {
HoverKind kind = HoverKind::kNone; HoverKind kind = HoverKind::kNone;
@@ -146,11 +158,19 @@ private:
#ifdef _WIN32 #ifdef _WIN32
void paint(HDC hdc); 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 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 paintZone(LICE_IBitmap* bmp, int w, int h); // S-VIEW-8 zone surface
void paintEmptyState(LICE_IBitmap* bmp, const Rect& area); 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 // 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. // `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, void paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, const PerformanceZone& zone,
@@ -159,7 +179,9 @@ private:
// Y = amp 0-1), the monotone spline traced by eval, one draggable node handle per control // Y = amp 0-1), the monotone spline traced by eval, one draggable node handle per control
// point. Shared by the Sample face (beside the hero) and the Zone param panel; all mapping / // point. Shared by the Sample face (beside the hero) and the Zone param panel; all mapping /
// hit-test / clamp math lives in the pure velocity_curve module. `r` empty -> draws nothing. // hit-test / clamp math lives in the pure velocity_curve module. `r` empty -> draws nothing.
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone); // drawCaption: false suppresses the "Vel curve" corner label (the popup draws its own title).
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone,
bool drawCaption = true);
// Route a mouse-down inside curve-editor box `r` editing map_.zones[zoneIndex]: a node grab // Route a mouse-down inside curve-editor box `r` editing map_.zones[zoneIndex]: a node grab
// starts a kCurveNode drag; Alt-click on an interior node deletes it (committed at once); // starts a kCurveNode drag; Alt-click on an interior node deletes it (committed at once);
@@ -170,6 +192,11 @@ private:
void onMouseDown(int x, int y); void onMouseDown(int x, int y);
void onMouseMove(int x, int y); void onMouseMove(int x, int y);
void onMouseUp(int x, int y); void onMouseUp(int x, int y);
// 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]: // 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), // a toggle segment commits immediately, a slider grab starts a live param-drag (kParamSlider),
@@ -337,6 +364,37 @@ private:
// needs a concrete zone to write. Returns -1 if selectedId_ is empty. // needs a concrete zone to write. Returns -1 if selectedId_ is empty.
int ensureSampleZone(); 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; ReaSamplerProcessor* processor_ = nullptr;
// --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) --- // --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) ---
@@ -444,6 +502,15 @@ private:
Rect dragCurveRect_{}; Rect dragCurveRect_{};
int dragCurveZone_ = -1; 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) ------ // --- Peak-thumbnail cache (mirror of bank_panel; id -> envelope at a bin width) ------
// Keyed by "id|binCount" so a resize recomputes at the new width. Cleared on refresh so // Keyed by "id|binCount" so a resize recomputes at the new width. Cleared on refresh so
// a bank edit (a re-captured or deleted sample) does not show a stale thumbnail. // a bank edit (a re-captured or deleted sample) does not show a stale thumbnail.
+67
View File
@@ -22,6 +22,7 @@
#include "bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision #include "bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution) #include "capture_paths.h" // resolveBankFile (shared M4 path resolution)
#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey (shared wire contract) #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_editor.h"
#include "reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there) #include "reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there)
#include "sample_map.h" // selectSample, resolvePerformance, buildZonedKeymap, state (de)ser #include "sample_map.h" // selectSample, resolvePerformance, buildZonedKeymap, state (de)ser
@@ -42,6 +43,13 @@ namespace {
// FIXED so raising the voice count never multiplies shifter CPU past the profiled budget. // FIXED so raising the voice count never multiplies shifter CPU past the profiled budget.
constexpr std::size_t kPreserveVoiceCap = 8; constexpr std::size_t kPreserveVoiceCap = 8;
// FB1 post-mixer gain ramp rate (per sample). gainCurrent_ converges to masterGain_ at this
// linear step; it ramps from 0 to unity (or vice versa) in ~20 ms at 48 kHz. The early-out
// (|current - target| < threshold) snaps to the target and avoids the ramp loop on idle blocks.
// kGainRampSnap is the threshold below which we snap to the target (avoids long sub-LSB creep).
constexpr float kGainRampRate = 1.0f / 960.0f; // 960 samples @ 48 kHz ≈ 20 ms
constexpr float kGainRampSnap = kGainRampRate * 0.5f;
// Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on // Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on
// any failure — the caller treats an unreadable WAV as "nothing to play". // any failure — the caller treats an unreadable WAV as "nothing to play".
std::vector<std::uint8_t> readFileBytes(const std::string& path) { std::vector<std::uint8_t> readFileBytes(const std::string& path) {
@@ -224,6 +232,10 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
voiceMode_ = cs.voiceMode; voiceMode_ = cs.voiceMode;
monoTrigger_ = cs.monoTrigger; 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). // Rebuild from the restored state (off-thread — setState is a load-time call).
reloadFromBank(); reloadFromBank();
return kResultOk; return kResultOk;
@@ -252,6 +264,7 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
state_out.voiceMode = voiceMode_; state_out.voiceMode = voiceMode_;
state_out.monoTrigger = monoTrigger_; 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); const std::vector<std::uint8_t> bytes = serializeComponentState(state_out);
if (!bytes.empty()) { if (!bytes.empty()) {
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()), const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
@@ -351,6 +364,16 @@ void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) {
rebuildVoiceEngine(); 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) { void ReaSamplerProcessor::previewNoteOn(int note) {
if (note < 0) note = 0; if (note < 0) note = 0;
if (note > 127) note = 127; if (note > 127) note = 127;
@@ -877,6 +900,30 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
drain->engine.render(ch0, ch1, static_cast<std::size_t>(frames)); drain->engine.render(ch0, ch1, static_cast<std::size_t>(frames));
drain->preview.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: ramp gainCurrent_ toward the atomic target per-sample so
// continuous knob drags produce no zipper noise and the true-zero bottom causes no click.
// Applied AFTER the voice sum and BEFORE the extra-channel mirror + peak so both see the
// actual output. Branch-free inner loop; early-out when already at target. RT-safe.
{
const float gTarget = masterGain_.load(std::memory_order_relaxed);
const float diff = gTarget - gainCurrent_;
if (diff < -kGainRampSnap || diff > kGainRampSnap) {
// Ramp toward target: step per sample, then apply the per-sample gain.
for (int32 i = 0; i < frames; ++i) {
const float d = gTarget - gainCurrent_;
if (d > kGainRampRate) gainCurrent_ += kGainRampRate;
else if (d < -kGainRampRate) gainCurrent_ -= kGainRampRate;
else gainCurrent_ = gTarget;
ch0[i] *= gainCurrent_;
ch1[i] *= gainCurrent_;
}
} else {
gainCurrent_ = gTarget;
if (gTarget != 1.f) {
for (int32 i = 0; i < frames; ++i) { ch0[i] *= gTarget; ch1[i] *= gTarget; }
}
}
}
// Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2). // Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2).
for (int32 ch = 2; ch < out.numChannels; ++ch) { for (int32 ch = 2; ch < out.numChannels; ++ch) {
if (float* buf = out.channelBuffers32[ch]) { if (float* buf = out.channelBuffers32[ch]) {
@@ -904,6 +951,26 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
drain->engine.render(ch0, static_cast<std::size_t>(frames)); drain->engine.render(ch0, static_cast<std::size_t>(frames));
drain->preview.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 ramp contract as the stereo branch:
// post-sum, pre-peak/replicate, per-sample gainCurrent_ ramp toward target, RT-safe.
{
const float gTarget = masterGain_.load(std::memory_order_relaxed);
const float diff = gTarget - gainCurrent_;
if (diff < -kGainRampSnap || diff > kGainRampSnap) {
for (int32 i = 0; i < frames; ++i) {
const float d = gTarget - gainCurrent_;
if (d > kGainRampRate) gainCurrent_ += kGainRampRate;
else if (d < -kGainRampRate) gainCurrent_ -= kGainRampRate;
else gainCurrent_ = gTarget;
ch0[i] *= gainCurrent_;
}
} else {
gainCurrent_ = gTarget;
if (gTarget != 1.f) {
for (int32 i = 0; i < frames; ++i) ch0[i] *= gTarget;
}
}
}
float peak = 0.f; float peak = 0.f;
for (int32 i = 0; i < frames; ++i) { for (int32 i = 0; i < frames; ++i) {
const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i]; const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i];
+21
View File
@@ -210,6 +210,17 @@ public:
MonoTrigger monoTrigger(); MonoTrigger monoTrigger();
void setMonoTrigger(MonoTrigger trigger); 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 // 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 // (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. // pool, so a full pool never drops a preview and a preview never steals a playing voice.
@@ -355,6 +366,16 @@ private:
VoiceMode voiceMode_ = VoiceMode::Poly; VoiceMode voiceMode_ = VoiceMode::Poly;
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger; MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
// FB1 post-mixer master gain (LINEAR; persisted in component state v8). A lock-free
// atomic — the target the UI thread writes; the audio thread ramps gainCurrent_ toward
// it per-sample each block (linear interpolation, ~20 ms at 48 kHz / 256-frame block)
// so sudden knob moves produce no zipper noise and the true-zero bottom causes no click.
std::atomic<float> masterGain_{1.0f};
// The audio-thread running gain value: tracks masterGain_ across blocks, stepping at
// most kGainRampRate per sample toward the target. Starts at unity (pre-FB1 default).
// Written and read exclusively on the audio thread — no atomics needed.
float gainCurrent_ = 1.0f;
// --- S-VIEW-4 preview-trigger mailbox (off-thread -> audio thread, lock-free) --------- // --- 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() // 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 // drains it at block start and drives the live instrument's PREVIEW CARD (Phase S — never the
+30 -3
View File
@@ -5,9 +5,12 @@
#include <algorithm> // std::min #include <algorithm> // std::min
#include <cassert> // assert #include <cassert> // assert
#include <cmath> // std::isfinite (v8 master-gain validation)
#include <cstring> // std::memcpy #include <cstring> // std::memcpy
#include <utility> // std::move #include <utility> // std::move
#include "master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap
namespace reasampler { namespace reasampler {
namespace { 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(static_cast<std::uint8_t>(vc));
out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0); out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0);
out.push_back(state.monoTrigger == MonoTrigger::Legato ? 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 — // 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). // unlike the v1 selection blob where the id ran to end-of-stream).
putU32le(out, static_cast<std::uint32_t>(state.selectionId.size())); 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) return out; // previewVelocity stays at the mid default (pre-S-VIEW-4)
} }
if (version != kComponentStateVersion && if (version != kComponentStateVersion &&
version != kSelectionZonesModeMarkerVelVoiceV7Version &&
version != kSelectionZonesModeMarkerVelV6Version) { version != kSelectionZonesModeMarkerVelV6Version) {
return out; // unknown -> empty 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 // 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 // as mono (conservative default) rather than rejected — a corrupt mode never silences the
// instance. // instance.
@@ -711,9 +726,9 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
out.previewVelocity = (previewVel >= 1 && previewVel <= 127) out.previewVelocity = (previewVel >= 1 && previewVel <= 127)
? previewVel ? previewVel
: kPreviewVelocityDefault; : 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. // 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 vc = r.u8();
const std::uint8_t vm = r.u8(); const std::uint8_t vm = r.u8();
const std::uint8_t mt = r.u8(); const std::uint8_t mt = r.u8();
@@ -726,6 +741,18 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly; out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly;
out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger; 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(r.u64());
if (!r.ok) return out; // truncated inside the gain double — unity holds (out already
// carries mode/marker/velocity/voice fields from above)
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(); const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen); out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
+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 // instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
// state), never auto-playing sample #1. // state), never auto-playing sample #1.
// //
// Format (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 = 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 // 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 // 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 // trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754
// CURRENT zones payload (identical to serializePerformance's body — its own self-describing // double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then a 4-byte LE
// version, see the ZONES-PAYLOAD block). The three voice bytes are the ONLY envelope-v7 addition // selection-id length + id bytes, then the CURRENT zones payload (identical to
// over envelope-v6 — the envelope grew fields, the zones payload is untouched (a PARALLEL track // serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
// owns zone-record extension under its own versioning; the two version numbers are independent // The master-gain double is the ONLY envelope-v8 addition over v7 — the envelope grew a field,
// axes — do NOT bump the zones-payload version for an envelope field). An out-of-range voice // the zones payload is untouched (a PARALLEL track owns zone-record extension under its own
// byte (a corrupt blob) falls back to the field's default rather than silencing the instance // versioning; the two version numbers are independent axes — do NOT bump the zones-payload
// (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to channelMode = // version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range
// MONO, lastConsumedAssignGeneration = 0, previewVelocity = kPreviewVelocityDefault, and the // master-gain double (a corrupt blob) falls back to the field's default rather than silencing
// Phase-S voice defaults {16 voices, Poly, Retrigger} — which reproduce pre-Phase-S behavior // the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to
// exactly — preserving current behavior for already-saved instances): // channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity =
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones} direct. // 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). // * 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). // * 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). // * 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 int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack) VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato 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 + // 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 // preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a
+95
View File
@@ -0,0 +1,95 @@
// Standalone tests for reasampler::vst::curve_popup — no VST3, no REAPER, no framework. Same
// fast assert loop as the sibling pure tests. Assert the r11 popup-sheet geometry at the size
// clamps (the spec's width clamp(60%, 360..520) / height clamp(55%, 260..380)), the centering,
// the title-row/close-button placement, the curve-box remainder, and the outside-sheet
// dismissal test.
#include "../src/vst/curve_popup.h"
#include <cstdio>
using namespace reasampler::vst;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static void testDefaultWindowMidClamp() {
// 840x620: 60% = 504 (inside 360..520), 55% = 341 (inside 260..380).
const CurvePopupLayout pl = computeCurvePopup(840, 620);
CHECK(pl.sheet.width() == 504);
CHECK(pl.sheet.height() == 341);
// Centered (within the integer-division pixel).
CHECK(pl.sheet.left == (840 - 504) / 2);
CHECK(pl.sheet.top == (620 - 341) / 2);
}
static void testMinClamp() {
// The 560x460 constraint floor: 60% = 336 -> clamps UP to 360; 55% = 253 -> up to 260.
const CurvePopupLayout pl = computeCurvePopup(560, 460);
CHECK(pl.sheet.width() == kCurvePopupMinW);
CHECK(pl.sheet.height() == kCurvePopupMinH);
CHECK(pl.sheet.left >= 0 && pl.sheet.right <= 560);
CHECK(pl.sheet.top >= 0 && pl.sheet.bottom <= 460);
}
static void testMaxClamp() {
// A large window: 60% of 1600 = 960 -> clamps DOWN to 520; 55% of 900 = 495 -> down to 380.
const CurvePopupLayout pl = computeCurvePopup(1600, 900);
CHECK(pl.sheet.width() == kCurvePopupMaxW);
CHECK(pl.sheet.height() == kCurvePopupMaxH);
}
static void testDegenerateWindowNeverOverhangs() {
// A window smaller than the min clamp: the sheet caps at the window dimension (defensive —
// below checkSizeConstraint, but geometry must stay sane).
const CurvePopupLayout pl = computeCurvePopup(300, 200);
CHECK(pl.sheet.width() == 300);
CHECK(pl.sheet.height() == 200);
CHECK(pl.sheet.left == 0 && pl.sheet.top == 0);
}
static void testTitleRowAndCurveBox() {
const CurvePopupLayout pl = computeCurvePopup(840, 620);
// Close: 18x18, right-anchored inside the title row.
CHECK(pl.close.width() == kCurvePopupCloseSize && pl.close.height() == kCurvePopupCloseSize);
CHECK(pl.close.right == pl.sheet.right - kCurvePopupPad);
CHECK(pl.close.top >= pl.sheet.top);
CHECK(pl.close.bottom <= pl.sheet.top + kCurvePopupTitleH);
// Title text: left of the close button, in the title row.
CHECK(pl.title.left == pl.sheet.left + kCurvePopupPad);
CHECK(pl.title.right <= pl.close.left);
// Curve box: fills the remainder below the title row, inside the sheet margins.
CHECK(pl.curveBox.top >= pl.sheet.top + kCurvePopupTitleH);
CHECK(pl.curveBox.left == pl.sheet.left + kCurvePopupPad);
CHECK(pl.curveBox.right == pl.sheet.right - kCurvePopupPad);
CHECK(pl.curveBox.bottom == pl.sheet.bottom - kCurvePopupPad);
CHECK(pl.curveBox.width() > 0 && pl.curveBox.height() > 0);
}
static void testOutsideSheetDismissTest() {
const CurvePopupLayout pl = computeCurvePopup(840, 620);
// On the wash: outside.
CHECK(popupOutsideSheet(pl, 0, 0));
CHECK(popupOutsideSheet(pl, pl.sheet.left - 1, pl.sheet.top + 10));
CHECK(popupOutsideSheet(pl, pl.sheet.right, pl.sheet.top + 10)); // half-open right edge
// On the sheet (title row, curve box, padding): inside.
CHECK(!popupOutsideSheet(pl, pl.sheet.left, pl.sheet.top));
CHECK(!popupOutsideSheet(pl, pl.curveBox.left + 5, pl.curveBox.top + 5));
CHECK(!popupOutsideSheet(pl, pl.sheet.right - 1, pl.sheet.bottom - 1));
}
int main() {
testDefaultWindowMidClamp();
testMinClamp();
testMaxClamp();
testDegenerateWindowNeverOverhangs();
testTitleRowAndCurveBox();
testOutsideSheetDismissTest();
if (g_fail) {
std::printf("%d FAILURE(S)\n", g_fail);
return 1;
}
std::printf("curve_popup tests passed\n");
return 0;
}
+199
View File
@@ -0,0 +1,199 @@
// Standalone tests for reasampler::vst::knob_deck — no VST3, no REAPER, no framework. Same fast
// assert loop as the sibling pure tests. Assert the r11 deck layout HARD:
//
// * group width — caption row vs knob row max + padding; row-toggle and caption-toggle widths.
// * layout — caption toggle right-anchored IN the caption row; cells fixed 48x58 left-to-right
// inside the box; knob square centered; label band beneath; row toggle after the cells.
// * wrap — deterministic whole-group wrap at a narrowing width (the r11 "PITCH ENV onto row
// two at the 560 floor" behavior); the first group of a row always places; deckHeight
// consistency with deckRowCount.
// * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, blank (-1) cells
// and fence padding miss, outside-deck miss.
#include "../src/vst/knob_deck.h"
#include <cstdio>
#include <vector>
using namespace reasampler::vst;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// A representative deck shaped like the shell's: AMP (5 cells + caption toggle), PITCH
// (1 cell + caption toggle), PITCH ENV (3 cells + caption toggle), VOICE (1 cell + caption
// toggle + row toggle), MASTER (1 cell, no toggle).
static std::vector<DeckGroupDesc> shellLikeDeck() {
std::vector<DeckGroupDesc> g;
g.push_back({0, 78, {100, 44}, {1, 2, 3, 4, 5}, {}});
g.push_back({1, 38, {101, 48}, {6}, {}});
g.push_back({2, 58, {102, 32}, {7, 8, 9}, {}});
g.push_back({3, 38, {103, 40}, {10}, {104, 44}});
g.push_back({4, 46, {}, {11}, {}});
return g;
}
static void testGroupWidth() {
// Knob row dominates: 5 cells (240) > caption row (78 + 4 + 88 = 170) -> 240 + 2*6.
DeckGroupDesc amp{0, 78, {100, 44}, {1, 2, 3, 4, 5}, {}};
CHECK(deckGroupWidth(amp) == 5 * kDeckCellW + 2 * kDeckGroupPadX);
// Caption row dominates: 38 + 4 + 96 = 138 > 48 -> 138 + 12.
DeckGroupDesc pitch{1, 38, {101, 48}, {6}, {}};
CHECK(deckGroupWidth(pitch) == 38 + kDeckToggleGap + 2 * 48 + 2 * kDeckGroupPadX);
// Row toggle counts into the knob row: 48 + 4 + 88 = 140 > caption 38+4+80=122.
DeckGroupDesc voice{3, 38, {103, 40}, {10}, {104, 44}};
CHECK(deckGroupWidth(voice) ==
kDeckCellW + kDeckToggleGap + 2 * 44 + 2 * kDeckGroupPadX);
// No toggles: max(caption, cells) + padding.
DeckGroupDesc master{4, 46, {}, {11}, {}};
CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX);
}
static void testShellDeckFitsOneRowAtDefaultWidth() {
// The r11 default window is 840 with kPad=8 margins -> 824 available. The five shell
// groups must fit ONE deck row there (the elastic hero keeps ~430px — the layout spec's
// premise). Locks the constants against accidental growth.
const auto deck = shellLikeDeck();
int total = 0;
for (const auto& g : deck) total += deckGroupWidth(g);
total += (static_cast<int>(deck.size()) - 1) * kDeckGroupGap;
CHECK(total <= 824);
CHECK(deckRowCount(deck, 824) == 1);
CHECK(deckHeight(deck, 824) == kDeckGroupH);
}
static void testWrapAtNarrowWidthIsDeterministic() {
// At the 560x460 checkSizeConstraint floor (544 available) the deck wraps to TWO rows,
// whole trailing groups only.
const auto deck = shellLikeDeck();
CHECK(deckRowCount(deck, 544) == 2);
CHECK(deckHeight(deck, 544) == 2 * kDeckGroupH + kDeckRowGap);
const DeckLayout dl = layoutDeck(deck, 8, 100, 544);
CHECK(dl.rowCount == 2);
CHECK(dl.height == deckHeight(deck, 544));
CHECK(dl.groups.size() == 5);
// Row membership: groups on row 1 share the first top; the wrapped groups sit one row
// pitch lower and restart at the left margin.
const int row0Top = dl.groups[0].box.top;
const int row1Top = row0Top + kDeckGroupH + kDeckRowGap;
CHECK(dl.groups[0].box.top == row0Top);
CHECK(dl.groups[1].box.top == row0Top);
bool sawWrap = false;
for (std::size_t i = 1; i < dl.groups.size(); ++i) {
if (dl.groups[i].box.top == row1Top && dl.groups[i - 1].box.top == row0Top) {
CHECK(dl.groups[i].box.left == 8); // wrapped row restarts at the left edge
sawWrap = true;
}
}
CHECK(sawWrap);
// Every box stays within the available width (no group straddles the right edge).
for (const auto& g : dl.groups) CHECK(g.box.right <= 8 + 544);
}
static void testFirstGroupAlwaysPlaces() {
// A group wider than the row still places (degenerate width) — exactly one row per group.
const auto deck = shellLikeDeck();
CHECK(deckRowCount(deck, 100) == 5);
CHECK(deckHeight(deck, 100) == 5 * kDeckGroupH + 4 * kDeckRowGap);
}
static void testGroupInnerGeometry() {
const auto deck = shellLikeDeck();
const DeckLayout dl = layoutDeck(deck, 8, 50, 824);
const DeckGroupLayout& amp = dl.groups[0];
// Caption row at the top padding; caption toggle right-anchored inside the box.
CHECK(amp.caption.top == amp.box.top + kDeckGroupPadY);
CHECK(amp.captionToggle.id == 100);
CHECK(amp.captionToggle.seg1.right == amp.box.right - kDeckGroupPadX);
CHECK(amp.captionToggle.seg0.right == amp.captionToggle.seg1.left);
CHECK(amp.captionToggle.seg0.width() == 44 && amp.captionToggle.seg1.width() == 44);
CHECK(amp.captionToggle.seg0.height() == kDeckToggleH);
// The caption text rect stops before the toggle.
CHECK(amp.caption.right <= amp.captionToggle.seg0.left);
// Cells: five, fixed size, abutting, inside the box, below the caption row.
CHECK(static_cast<int>(amp.cells.size()) == 5);
for (std::size_t i = 0; i < amp.cells.size(); ++i) {
const DeckCellLayout& c = amp.cells[i];
CHECK(c.cell.width() == kDeckCellW && c.cell.height() == kDeckCellH);
CHECK(c.cell.top == amp.box.top + kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap);
if (i > 0) CHECK(c.cell.left == amp.cells[i - 1].cell.right);
// Knob square centered horizontally, label band beneath it, both inside the cell.
CHECK(c.knob.width() == kDeckKnobSize && c.knob.height() == kDeckKnobSize);
CHECK(c.knob.left - c.cell.left == c.cell.right - c.knob.right);
CHECK(c.label.top >= c.knob.bottom);
CHECK(c.label.bottom <= c.cell.bottom);
}
// VOICE group's row toggle sits after its cell, vertically centered in the cell row.
const DeckGroupLayout& voice = dl.groups[3];
CHECK(voice.rowToggle.id == 104);
CHECK(voice.rowToggle.seg0.left == voice.cells[0].cell.right + kDeckToggleGap);
CHECK(voice.rowToggle.seg0.height() == kDeckToggleH);
CHECK(voice.rowToggle.seg0.top > voice.cells[0].cell.top);
// MASTER has no toggles.
CHECK(dl.groups[4].captionToggle.id == -1);
CHECK(dl.groups[4].rowToggle.id == -1);
}
static void testHitTest() {
const auto deck = shellLikeDeck();
const DeckLayout dl = layoutDeck(deck, 8, 50, 824);
const DeckGroupLayout& amp = dl.groups[0];
// Knob hit: anywhere in the cell (including the label band) resolves to the cell id.
const DeckCellLayout& c0 = amp.cells[0];
DeckHit h = hitTestDeck(dl, c0.cell.left + 1, c0.cell.top + 1);
CHECK(h.kind == DeckHitKind::Knob && h.id == 1 && h.segment == -1);
h = hitTestDeck(dl, c0.label.left + 2, c0.label.top + 2);
CHECK(h.kind == DeckHitKind::Knob && h.id == 1);
// Caption toggle segments 0/1 at their boundary: last px of seg0, first px of seg1.
h = hitTestDeck(dl, amp.captionToggle.seg0.right - 1, amp.captionToggle.seg0.top + 1);
CHECK(h.kind == DeckHitKind::CaptionToggle && h.id == 100 && h.segment == 0);
h = hitTestDeck(dl, amp.captionToggle.seg1.left, amp.captionToggle.seg1.top + 1);
CHECK(h.kind == DeckHitKind::CaptionToggle && h.id == 100 && h.segment == 1);
// Row toggle.
const DeckGroupLayout& voice = dl.groups[3];
h = hitTestDeck(dl, voice.rowToggle.seg1.left + 1, voice.rowToggle.seg1.top + 1);
CHECK(h.kind == DeckHitKind::RowToggle && h.id == 104 && h.segment == 1);
// A blank cell (id -1) misses even though its rect exists.
std::vector<DeckGroupDesc> trig;
trig.push_back({0, 78, {100, 44}, {20, 21, 22, -1, -1}, {}});
const DeckLayout tl = layoutDeck(trig, 0, 0, 824);
const DeckCellLayout& blank = tl.groups[0].cells[4];
CHECK(blank.id == -1);
h = hitTestDeck(tl, blank.cell.left + 5, blank.cell.top + 5);
CHECK(h.kind == DeckHitKind::None);
// The fence padding inside the box misses; outside the deck misses.
h = hitTestDeck(dl, amp.box.left + 1, amp.box.bottom - 1);
CHECK(h.kind == DeckHitKind::None);
h = hitTestDeck(dl, -50, -50);
CHECK(h.kind == DeckHitKind::None);
}
static void testEmptyDeck() {
const std::vector<DeckGroupDesc> none;
CHECK(deckRowCount(none, 800) == 0);
CHECK(deckHeight(none, 800) == 0);
const DeckLayout dl = layoutDeck(none, 0, 0, 800);
CHECK(dl.groups.empty() && dl.rowCount == 0 && dl.height == 0);
}
int main() {
testGroupWidth();
testShellDeckFitsOneRowAtDefaultWidth();
testWrapAtNarrowWidthIsDeterministic();
testFirstGroupAlwaysPlaces();
testGroupInnerGeometry();
testHitTest();
testEmptyDeck();
if (g_fail) {
std::printf("%d FAILURE(S)\n", g_fail);
return 1;
}
std::printf("knob_deck tests passed\n");
return 0;
}
+119
View File
@@ -0,0 +1,119 @@
// Standalone tests for reasampler::vst::master_gain — no VST3, no REAPER, no framework. Same
// fast assert loop as the sibling pure tests. Assert the FB1 post-mixer gain taper HARD:
//
// * -inf bottom — norm 0 maps to -infinity dB and TRUE ZERO linear (silence, not an epsilon);
// linear <= 0 maps back to norm 0.
// * endpoints — norm 1 = +24 dB = masterGainMaxLinear() (~15.849); the finite floor at -60 dB.
// * unity — 0 dB round-trips exactly through norm<->dB<->linear (the knob's unity detent
// position 60/84 of the travel).
// * monotonicity — more norm never means less gain.
// * label — "-inf" at the bottom, signed one-decimal dB elsewhere.
#include "../src/vst/master_gain.h"
#include <cmath>
#include <cstdio>
#include <cstring>
using namespace reasampler::vst;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b) <= eps; }
static void testBottomIsTrueSilence() {
CHECK(std::isinf(masterGainDbFromNorm(0.0)) && masterGainDbFromNorm(0.0) < 0.0);
CHECK(masterGainLinearFromNorm(0.0) == 0.0); // exactly zero — the -inf contract
CHECK(masterGainNormFromLinear(0.0) == 0.0);
CHECK(masterGainNormFromLinear(-1.0) == 0.0);
CHECK(masterGainNormFromDb(-1e9) == 0.0);
}
static void testEndpoints() {
CHECK(near(masterGainDbFromNorm(1.0), kMasterGainMaxDb));
CHECK(near(masterGainLinearFromNorm(1.0), masterGainMaxLinear()));
CHECK(near(masterGainMaxLinear(), std::pow(10.0, 24.0 / 20.0)));
CHECK(near(masterGainNormFromDb(kMasterGainMaxDb), 1.0));
CHECK(near(masterGainNormFromLinear(masterGainMaxLinear()), 1.0));
// Out-of-range norms clamp.
CHECK(near(masterGainDbFromNorm(2.0), kMasterGainMaxDb));
CHECK(masterGainLinearFromNorm(-0.5) == 0.0);
}
static void testUnityRoundTrip() {
// 0 dB sits at norm -minDb/(maxDb-minDb) = 60/84 of the travel.
const double unityNorm = masterGainNormFromDb(0.0);
CHECK(near(unityNorm, 60.0 / 84.0, 1e-12));
CHECK(near(masterGainDbFromNorm(unityNorm), 0.0, 1e-9));
CHECK(near(masterGainLinearFromNorm(unityNorm), 1.0, 1e-9));
CHECK(near(masterGainNormFromLinear(1.0), unityNorm, 1e-9));
}
static void testNormLinearRoundTripAcrossTravel() {
for (int i = 1; i <= 20; ++i) {
const double norm = i / 20.0;
const double lin = masterGainLinearFromNorm(norm);
CHECK(lin > 0.0);
CHECK(near(masterGainNormFromLinear(lin), norm, 1e-9));
}
}
static void testMonotonic() {
double prev = -1.0;
for (int i = 0; i <= 100; ++i) {
const double lin = masterGainLinearFromNorm(i / 100.0);
CHECK(lin > prev); // strictly increasing over the whole travel (0 at the bottom)
prev = lin;
}
}
static void testNonFiniteLinearClamps() {
CHECK(masterGainNormFromLinear(std::nan("")) == 0.0);
CHECK(near(masterGainNormFromLinear(1e9), 1.0)); // above the cap clamps to 1
}
static void testBelowFloorCollapsesToBottom() {
// Any linear gain at or below the kMasterGainMinDb floor collapses to norm 0 (the -inf
// bottom detent). The floor IS the bottom of the finite sweep, so -61 dB, -80 dB, and
// a vanishingly small positive linear all map to the same place as true zero.
const double floorLinear = std::pow(10.0, kMasterGainMinDb / 20.0); // 10^(-60/20) = 0.001
CHECK(masterGainNormFromLinear(floorLinear) == 0.0); // exactly at the floor -> bottom
CHECK(masterGainNormFromLinear(floorLinear * 0.5) == 0.0); // below the floor -> bottom
CHECK(masterGainNormFromLinear(1e-9) == 0.0); // tiny positive -> bottom
// The dB path agrees: anything at or below kMasterGainMinDb maps to norm 0.
CHECK(masterGainNormFromDb(kMasterGainMinDb) == 0.0);
CHECK(masterGainNormFromDb(kMasterGainMinDb - 20.0) == 0.0); // -80 dB -> bottom
// Just ABOVE the floor (by epsilon) returns a non-zero norm.
CHECK(masterGainNormFromDb(kMasterGainMinDb + 1e-9) > 0.0);
}
static void testLabels() {
char buf[24];
formatMasterGainLabel(0.0, buf, sizeof(buf));
CHECK(std::strcmp(buf, "-inf") == 0);
formatMasterGainLabel(1.0, buf, sizeof(buf));
CHECK(std::strcmp(buf, "+24.0dB") == 0);
formatMasterGainLabel(masterGainNormFromDb(0.0), buf, sizeof(buf));
CHECK(std::strcmp(buf, "+0.0dB") == 0);
formatMasterGainLabel(masterGainNormFromDb(-12.0), buf, sizeof(buf));
CHECK(std::strcmp(buf, "-12.0dB") == 0);
}
int main() {
testBottomIsTrueSilence();
testEndpoints();
testUnityRoundTrip();
testNormLinearRoundTripAcrossTravel();
testMonotonic();
testNonFiniteLinearClamps();
testBelowFloorCollapsesToBottom();
testLabels();
if (g_fail) {
std::printf("%d FAILURE(S)\n", g_fail);
return 1;
}
std::printf("master_gain tests passed\n");
return 0;
}
+106
View File
@@ -32,6 +32,7 @@
#include "../src/bank_book.h" #include "../src/bank_book.h"
#include "../src/bank_model.h" #include "../src/bank_model.h"
#include "../src/vst/master_gain.h" // masterGainMaxLinear (the v8 master-gain wire cap)
using namespace reasampler; using namespace reasampler;
@@ -1220,6 +1221,105 @@ static void testComponentStateV7TruncatedVoiceBytes() {
CHECK(back.selectionId.empty() && back.map.zones.empty()); CHECK(back.selectionId.empty() && back.map.zones.empty());
} }
// --- v8 component state: the FB1 post-mixer master gain (linear double) -----------------------
static void testComponentStateMasterGainRoundTrip() {
// A non-default gain proves the bytes are read back, not defaulted; the envelope
// neighbours (voice bytes, velocity, selection, zones) ride alongside intact.
ComponentState s;
s.selectionId = "pick";
s.previewVelocity = 99;
s.voiceCount = 5;
s.masterGainLinear = 0.25; // -12.04 dB
s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(std::fabs(back.masterGainLinear - 0.25) < 1e-12); // an exact double round-trip
CHECK(back.voiceCount == 5);
CHECK(back.previewVelocity == 99);
CHECK(back.selectionId == "pick");
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0");
}
static void testComponentStateMasterGainDefaultAndZeroRoundTrip() {
// Default unity round-trips (pre-FB1 output); the -inf bottom (TRUE zero) round-trips
// exactly — a user who pulled the gain to silence gets silence back after a save/load.
const ComponentState defBack =
deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0);
CHECK(defBack.masterGainLinear == 1.0);
ComponentState zero;
zero.masterGainLinear = 0.0;
const ComponentState zeroBack =
deserializeComponentState(serializeComponentState(zero), 44100.0);
CHECK(zeroBack.masterGainLinear == 0.0);
}
static void testComponentStateMasterGainWriterClamps() {
// The WRITER never emits an out-of-range value: above the +24 dB cap clamps to the cap;
// a negative/non-finite value (a programming error upstream) falls back to unity.
ComponentState hi;
hi.masterGainLinear = 1000.0;
CHECK(std::fabs(deserializeComponentState(serializeComponentState(hi), 44100.0)
.masterGainLinear -
vst::masterGainMaxLinear()) < 1e-9);
ComponentState lo;
lo.masterGainLinear = -5.0;
CHECK(deserializeComponentState(serializeComponentState(lo), 44100.0).masterGainLinear ==
1.0);
}
static void testComponentStateV7LiftsUnityMasterGain() {
// A GENUINE v7 blob (version tag 7: mode, marker, velocity, voice bytes, id, zones — NO
// master-gain double) lifts to unity, its other fields intact. Hand-built
// (serializeComponentState now emits v8, so it cannot make a v7 blob). Proves an
// already-saved pre-FB1 instance restores playing at exactly its old output level.
std::vector<std::uint8_t> v7;
v7.push_back(7); v7.push_back(0); v7.push_back(0); v7.push_back(0); // version 7
v7.push_back(1); // channel mode = stereo
for (int i = 0; i < 8; ++i) v7.push_back(0); // marker = 0
v7.push_back(111); // preview velocity
v7.push_back(5); // voice count
v7.push_back(1); // voice mode = mono
v7.push_back(1); // trigger = legato
const std::string id = "saved";
v7.push_back(static_cast<std::uint8_t>(id.size())); v7.push_back(0); v7.push_back(0); v7.push_back(0);
v7.insert(v7.end(), id.begin(), id.end());
v7.push_back(0); v7.push_back(0); v7.push_back(0); v7.push_back(0); // zone count 0
const ComponentState back = deserializeComponentState(v7, 44100.0);
CHECK(back.masterGainLinear == 1.0);
CHECK(back.voiceCount == 5);
CHECK(back.voiceMode == VoiceMode::Mono);
CHECK(back.monoTrigger == MonoTrigger::Legato);
CHECK(back.previewVelocity == 111);
CHECK(back.channelMode == ChannelMode::Stereo);
CHECK(back.selectionId == "saved");
CHECK(back.map.zones.empty());
}
static void testComponentStateV8CorruptMasterGainFallsBack() {
// A corrupt gain double (NaN) in a v8 blob falls back to unity (the previewVelocity
// corrupt-byte precedent) — never silences or blasts the instance. Build v8 by
// serializing, then vandalize the 8 gain bytes in place (offsets: 4 version + 1 mode +
// 8 marker + 1 velocity + 3 voice bytes = 17..24).
ComponentState s;
s.masterGainLinear = 0.5;
std::vector<std::uint8_t> bytes = serializeComponentState(s);
for (int i = 0; i < 8; ++i) bytes[17 + i] = 0xFF; // 0xFFFF... = a negative NaN pattern
const ComponentState back = deserializeComponentState(bytes, 44100.0);
CHECK(back.masterGainLinear == 1.0);
}
static void testComponentStateV8TruncatedMasterGain() {
// A v8 blob cut INSIDE the gain double -> empty, defaults holding (bounded read).
std::vector<std::uint8_t> t{8, 0, 0, 0, 0}; // version 8, mode byte
for (int i = 0; i < 8; ++i) t.push_back(0); // full marker
t.push_back(64); // velocity byte
t.push_back(16); t.push_back(0); t.push_back(0); // the three voice bytes
t.push_back(0); t.push_back(0); t.push_back(0); // gain cut mid-double
const ComponentState back = deserializeComponentState(t, 44100.0);
CHECK(back.masterGainLinear == 1.0);
CHECK(back.selectionId.empty() && back.map.zones.empty());
}
// --- MERGE COMPOSITION (S9 v5 marker envelope x S15/S16 v3 play-param payload) ---------------- // --- MERGE COMPOSITION (S9 v5 marker envelope x S15/S16 v3 play-param payload) ----------------
// //
// The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and // The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and
@@ -2030,6 +2130,12 @@ int main() {
testComponentStateV6LiftsVoiceDefaults(); testComponentStateV6LiftsVoiceDefaults();
testComponentStateV7CorruptVoiceBytesFallBack(); testComponentStateV7CorruptVoiceBytesFallBack();
testComponentStateV7TruncatedVoiceBytes(); testComponentStateV7TruncatedVoiceBytes();
testComponentStateMasterGainRoundTrip();
testComponentStateMasterGainDefaultAndZeroRoundTrip();
testComponentStateMasterGainWriterClamps();
testComponentStateV7LiftsUnityMasterGain();
testComponentStateV8CorruptMasterGainFallsBack();
testComponentStateV8TruncatedMasterGain();
testV5EnvelopeWithMarkerAndPlayParamsRoundTrip(); testV5EnvelopeWithMarkerAndPlayParamsRoundTrip();
testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay(); testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay();
testReconcileKeepsOnlySelectedFullRangeZone(); testReconcileKeepsOnlySelectedFullRangeZone();